Skip to content

Commit 9e258b7

Browse files
authored
Improve performance of UI when InfluxDB is slow (#200)
* Run InfluxDB and other requests concurrently * Lazy loading for Gardens * Add lazy loading to weather clients * Rename get_full to include_data * Don't swap full card for weather clients * Fix HTMX swapping for Garden cards * Rename lazy load endpoints and query params * Fix lint * Add ctx to WeatherClient methods * Fix remaining issues * Fix tests after rebase
1 parent 1972ac7 commit 9e258b7

34 files changed

+1597
-308
lines changed

deploy/configs/prometheus/prometheus.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ scrape_configs:
77
- targets: ["localhost:9090"]
88
- job_name: garden-app
99
static_configs:
10-
- targets: ["garden-app:8080"]
10+
- targets: ["host.docker.internal:8080", "garden-app:8080"]

garden-app/integration_tests/testdata/config.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ influxdb:
1010
org: "garden"
1111
bucket: "garden"
1212
storage:
13-
driver: sqlite
14-
options:
15-
data_source_name: "file:mem-integration-test?mode=memory&cache=shared"
13+
connection_string: "file:mem-integration-test?mode=memory&cache=shared"
1614

1715
controller:
1816
topic_prefix: "test"

garden-app/pkg/cache/cache.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Package cache provides a simple in-memory cache with TTL support.
2+
package cache
3+
4+
import (
5+
"sync"
6+
"time"
7+
)
8+
9+
// Entry represents a cached value with its expiration time.
10+
type Entry[T any] struct {
11+
Value T
12+
Expiration time.Time
13+
}
14+
15+
// Cache is a generic in-memory cache with TTL support.
16+
// T is the type of values stored in the cache.
17+
type Cache[T any] struct {
18+
mu sync.RWMutex
19+
entries map[string]Entry[T]
20+
ttl time.Duration
21+
}
22+
23+
// New creates a new Cache with the specified TTL for all entries.
24+
func New[T any](ttl time.Duration) *Cache[T] {
25+
return &Cache[T]{
26+
entries: make(map[string]Entry[T]),
27+
ttl: ttl,
28+
}
29+
}
30+
31+
// Get retrieves a value from the cache by key.
32+
// Returns the value and true if found and not expired, otherwise returns zero value and false.
33+
func (c *Cache[T]) Get(key string) (T, bool) {
34+
c.mu.RLock()
35+
defer c.mu.RUnlock()
36+
37+
entry, exists := c.entries[key]
38+
if !exists || time.Now().After(entry.Expiration) {
39+
var zero T
40+
return zero, false
41+
}
42+
return entry.Value, true
43+
}
44+
45+
// Set stores a value in the cache with the configured TTL.
46+
func (c *Cache[T]) Set(key string, value T) {
47+
c.mu.Lock()
48+
defer c.mu.Unlock()
49+
50+
c.entries[key] = Entry[T]{
51+
Value: value,
52+
Expiration: time.Now().Add(c.ttl),
53+
}
54+
}
55+
56+
// Delete removes a value from the cache by key.
57+
func (c *Cache[T]) Delete(key string) {
58+
c.mu.Lock()
59+
defer c.mu.Unlock()
60+
delete(c.entries, key)
61+
}
62+
63+
// Clear removes all entries from the cache.
64+
func (c *Cache[T]) Clear() {
65+
c.mu.Lock()
66+
defer c.mu.Unlock()
67+
c.entries = make(map[string]Entry[T])
68+
}
69+
70+
// Size returns the number of entries in the cache (including expired ones).
71+
func (c *Cache[T]) Size() int {
72+
c.mu.RLock()
73+
defer c.mu.RUnlock()
74+
return len(c.entries)
75+
}

garden-app/pkg/cache/cache_test.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package cache
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestCacheGetSet(t *testing.T) {
9+
cache := New[string](100 * time.Millisecond)
10+
11+
// Test Get on empty cache
12+
val, found := cache.Get("key1")
13+
if found {
14+
t.Error("expected key to not be found in empty cache")
15+
}
16+
if val != "" {
17+
t.Errorf("expected empty string, got %q", val)
18+
}
19+
20+
// Test Set and Get
21+
cache.Set("key1", "value1")
22+
val, found = cache.Get("key1")
23+
if !found {
24+
t.Error("expected key to be found after Set")
25+
}
26+
if val != "value1" {
27+
t.Errorf("expected 'value1', got %q", val)
28+
}
29+
30+
// Test Get on different key
31+
_, found = cache.Get("key2")
32+
if found {
33+
t.Error("expected key2 to not be found")
34+
}
35+
}
36+
37+
func TestCacheExpiration(t *testing.T) {
38+
cache := New[string](50 * time.Millisecond)
39+
40+
// Set a value
41+
cache.Set("key1", "value1")
42+
43+
// Value should be available immediately
44+
val, found := cache.Get("key1")
45+
if !found {
46+
t.Error("expected key to be found immediately after Set")
47+
}
48+
if val != "value1" {
49+
t.Errorf("expected 'value1', got %q", val)
50+
}
51+
52+
// Wait for expiration
53+
time.Sleep(100 * time.Millisecond)
54+
55+
// Value should be expired now
56+
val, found = cache.Get("key1")
57+
if found {
58+
t.Error("expected key to be expired after TTL")
59+
}
60+
if val != "" {
61+
t.Errorf("expected empty string for expired key, got %q", val)
62+
}
63+
}
64+
65+
func TestCacheDelete(t *testing.T) {
66+
cache := New[string](time.Minute)
67+
68+
// Set and then delete
69+
cache.Set("key1", "value1")
70+
cache.Delete("key1")
71+
72+
val, found := cache.Get("key1")
73+
if found {
74+
t.Error("expected key to not be found after Delete")
75+
}
76+
if val != "" {
77+
t.Errorf("expected empty string after Delete, got %q", val)
78+
}
79+
80+
// Delete non-existent key should not panic
81+
cache.Delete("nonexistent")
82+
}
83+
84+
func TestCacheClear(t *testing.T) {
85+
cache := New[string](time.Minute)
86+
87+
// Set multiple values
88+
cache.Set("key1", "value1")
89+
cache.Set("key2", "value2")
90+
cache.Set("key3", "value3")
91+
92+
// Clear all
93+
cache.Clear()
94+
95+
// All should be gone
96+
for _, key := range []string{"key1", "key2", "key3"} {
97+
val, found := cache.Get(key)
98+
if found {
99+
t.Errorf("expected %s to not be found after Clear", key)
100+
}
101+
if val != "" {
102+
t.Errorf("expected empty string for %s after Clear, got %q", key, val)
103+
}
104+
}
105+
}
106+
107+
func TestCacheSize(t *testing.T) {
108+
cache := New[string](time.Minute)
109+
110+
// Empty cache should have size 0
111+
if cache.Size() != 0 {
112+
t.Errorf("expected size 0 for empty cache, got %d", cache.Size())
113+
}
114+
115+
// Add entries
116+
cache.Set("key1", "value1")
117+
cache.Set("key2", "value2")
118+
119+
if cache.Size() != 2 {
120+
t.Errorf("expected size 2, got %d", cache.Size())
121+
}
122+
123+
// Delete one
124+
cache.Delete("key1")
125+
if cache.Size() != 1 {
126+
t.Errorf("expected size 1 after Delete, got %d", cache.Size())
127+
}
128+
129+
// Clear
130+
cache.Clear()
131+
if cache.Size() != 0 {
132+
t.Errorf("expected size 0 after Clear, got %d", cache.Size())
133+
}
134+
}
135+
136+
func TestCacheSizeIncludesExpired(t *testing.T) {
137+
cache := New[string](50 * time.Millisecond)
138+
139+
// Set a value and let it expire
140+
cache.Set("key1", "value1")
141+
time.Sleep(100 * time.Millisecond)
142+
143+
// Size should still include expired entries
144+
if cache.Size() != 1 {
145+
t.Errorf("expected size 1 (including expired), got %d", cache.Size())
146+
}
147+
148+
// But Get should not return it
149+
_, found := cache.Get("key1")
150+
if found {
151+
t.Error("expected expired key to not be found via Get")
152+
}
153+
}
154+
155+
func TestCacheOverwrite(t *testing.T) {
156+
cache := New[string](time.Minute)
157+
158+
// Set initial value
159+
cache.Set("key1", "value1")
160+
161+
// Overwrite with new value
162+
cache.Set("key1", "value2")
163+
164+
val, found := cache.Get("key1")
165+
if !found {
166+
t.Error("expected key to be found")
167+
}
168+
if val != "value2" {
169+
t.Errorf("expected 'value2', got %q", val)
170+
}
171+
172+
if cache.Size() != 1 {
173+
t.Errorf("expected size 1 after overwrite, got %d", cache.Size())
174+
}
175+
}
176+
177+
func TestCacheDifferentTypes(t *testing.T) {
178+
// Test with int type
179+
intCache := New[int](time.Minute)
180+
intCache.Set("key1", 42)
181+
val, found := intCache.Get("key1")
182+
if !found || val != 42 {
183+
t.Errorf("expected 42, got %d (found=%v)", val, found)
184+
}
185+
186+
// Test with struct type
187+
type TestStruct struct {
188+
Name string
189+
Value int
190+
}
191+
structCache := New[TestStruct](time.Minute)
192+
structCache.Set("key1", TestStruct{Name: "test", Value: 123})
193+
sval, found := structCache.Get("key1")
194+
if !found || sval.Name != "test" || sval.Value != 123 {
195+
t.Errorf("expected {test 123}, got %+v (found=%v)", sval, found)
196+
}
197+
}
198+
199+
func TestCacheConcurrentAccess(t *testing.T) {
200+
cache := New[int](time.Minute)
201+
202+
// Run concurrent operations
203+
done := make(chan bool, 10)
204+
205+
// Multiple goroutines writing
206+
for i := 0; i < 5; i++ {
207+
go func(id int) {
208+
for j := 0; j < 100; j++ {
209+
cache.Set(string(rune('a'+id)), j)
210+
}
211+
done <- true
212+
}(i)
213+
}
214+
215+
// Multiple goroutines reading
216+
for i := 0; i < 5; i++ {
217+
go func(id int) {
218+
for j := 0; j < 100; j++ {
219+
cache.Get(string(rune('a' + id)))
220+
}
221+
done <- true
222+
}(i)
223+
}
224+
225+
// Wait for all goroutines
226+
for i := 0; i < 10; i++ {
227+
<-done
228+
}
229+
230+
// Cache should still be in a valid state
231+
if cache.Size() != 5 {
232+
t.Errorf("expected size 5, got %d", cache.Size())
233+
}
234+
}

0 commit comments

Comments
 (0)