Skip to content

Commit

Permalink
Implement in-memory cache (#15)
Browse files Browse the repository at this point in the history
* feat(resty): Implement cache for requests

Add an optional in-memory configurable generic cache to resty.
Configure gobal-player to pass in cache to resty.
Pre-fetch all parts of the TUI so they are present in the cache.


* refactor(gobal-player): pass cache in as dependency

Fixes: #2
  • Loading branch information
jj-style authored Mar 13, 2024
1 parent fd8782f commit 39318a6
Show file tree
Hide file tree
Showing 11 changed files with 896 additions and 4 deletions.
3 changes: 2 additions & 1 deletion .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ packages:
github.com/jj-style/gobal-player/pkg/resty:
interfaces:
Client:
HttpClient:
HttpClient:
Cache:
24 changes: 24 additions & 0 deletions cmd/gobal-player-tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ func NewApp(gp globalplayer.GlobalPlayer, player audioplayer.Player, hc *http.Cl
os.Exit(code)
}

go a.prefetch()

a.initTui()
return a
}
Expand Down Expand Up @@ -316,3 +318,25 @@ func (a *app) getCatchupList(slug, id string) {
}
}
}

// pre-fetch all data so it's in the cache
func (a *app) prefetch() {
stations, err := a.gp.GetStations()
if err != nil {
return
}
brands := stations.PageProps.Feature.Blocks[0].Brands
if len(brands) == 0 {
return
}
for _, st := range brands {
cu, _ := a.gp.GetCatchup(st.Slug)
if len(cu.PageProps.CatchupInfo) > 0 {
for _, cu := range cu.PageProps.CatchupInfo {
go func() {
_, _ = a.gp.GetCatchupShows(st.Slug, cu.ID)
}()
}
}
}
}
6 changes: 5 additions & 1 deletion cmd/gobal-player-tui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/jj-style/gobal-player/cmd/gobal-player-tui/internal/config"
"github.com/jj-style/gobal-player/pkg/audioplayer"
"github.com/jj-style/gobal-player/pkg/globalplayer"
"github.com/jj-style/gobal-player/pkg/resty"
)

func init() {
Expand All @@ -26,7 +27,10 @@ func main() {
httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: config.C.Insecure}}}
checkOrRegenConfig(httpClient)

gp := globalplayer.NewClient(httpClient, config.C.BuildId)
// don't expire cache in the TUI
cache := resty.NewCache[[]byte](0)

gp := globalplayer.NewClient(httpClient, config.C.BuildId, cache)

player, cleanup, err := audioplayer.NewPlayer()
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ go 1.22.0

require (
github.com/adrg/libvlc-go/v3 v3.1.5
github.com/eko/gocache/lib/v4 v4.1.5
github.com/eko/gocache/store/go_cache/v4 v4.2.1
github.com/gdamore/tcell/v2 v2.7.4
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rivo/tview v0.0.0-20240225120200-5605142ca62e
github.com/samber/lo v1.39.0
github.com/sirupsen/logrus v1.9.3
Expand All @@ -13,16 +16,25 @@ require (
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
Expand All @@ -38,6 +50,7 @@ require (
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
469 changes: 469 additions & 0 deletions go.sum

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pkg/globalplayer/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ type gpClient struct {
rc resty.Client
}

func NewClient(hc *http.Client, apiKey string) GlobalPlayer {
func NewClient(hc *http.Client, apiKey string, cache resty.Cache[[]byte]) GlobalPlayer {
baseUrlWithApiKey, _ := url.JoinPath(baseUrl, apiKey)
restClient := resty.NewClient(
resty.WithBaseUrl(baseUrlWithApiKey),
resty.WithHttpClient(hc),
resty.WithCache(cache),
)
c := &gpClient{rc: restClient}
return c
Expand Down
49 changes: 49 additions & 0 deletions pkg/resty/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package resty

import (
"context"
"time"

"github.com/eko/gocache/lib/v4/cache"
gocache_store "github.com/eko/gocache/store/go_cache/v4"
gocache "github.com/patrickmn/go-cache"
)

// A simple cache interface
type Cache[T any] interface {
// Get the item with the given key
Get(context.Context, string) (T, error)
// Set the value against the given key
Set(context.Context, string, T) error
}

// generic wrapper around cache.Cache
type cacheImpl[T any] struct {
cache *cache.Cache[T]
}

// Create a new Cache with the given ttl expiration for items
func NewCache[T any](ttl time.Duration) Cache[T] {
gocacheClient := gocache.New(ttl, 10*time.Minute)
gocacheStore := gocache_store.NewGoCache(gocacheClient)
return &cacheImpl[T]{cache: cache.New[T](gocacheStore)}
}

func (c *cacheImpl[T]) Get(ctx context.Context, key string) (T, error) {
return c.cache.Get(ctx, key)
}

func (c *cacheImpl[T]) Set(ctx context.Context, key string, value T) error {
return c.cache.Set(ctx, key, value)
}

// nilCache is a Cache which does nothing
type nilCache[T any] struct{}

func (n *nilCache[T]) Get(context.Context, string) (T, error) {
return *new(T), nil
}

func (n *nilCache[T]) Set(context.Context, string, T) error {
return nil
}
133 changes: 133 additions & 0 deletions pkg/resty/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package resty

import (
"context"
"errors"
"testing"

"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/store"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)

func TestNilCache(t *testing.T) {
ctx := context.Background()
assert := assert.New(t)
nc := &nilCache[int]{}

var got int
var err error

// get
got, err = nc.Get(ctx, "a")
assert.NoError(err)
assert.Equal(0, got)

// set
err = nc.Set(ctx, "a", 1)
assert.NoError(err)

// get still nil
got, err = nc.Get(ctx, "a")
assert.NoError(err)
assert.Equal(0, got)
}

func TestCache_Get(t *testing.T) {
tests := []struct {
name string
key string
setup func(s *store.MockStoreInterface)
want int
assertErr assert.ErrorAssertionFunc
}{
{
name: "cache hit",
key: "key",
setup: func(s *store.MockStoreInterface) {
s.EXPECT().Get(gomock.Any(), "key").Return(1, nil)
},
want: 1,
assertErr: assert.NoError,
},
{
name: "cache miss",
key: "key",
setup: func(s *store.MockStoreInterface) {
s.EXPECT().Get(gomock.Any(), "key").Return(0, errors.New("item missing"))
},
want: 0,
assertErr: assert.Error,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()

gomockController := gomock.NewController(t)
mstore := store.NewMockStoreInterface(gomockController)

if tt.setup != nil {
tt.setup(mstore)
}

c := &cacheImpl[int]{cache: cache.New[int](mstore)}

var got int
var err error

got, err = c.Get(ctx, tt.key)
tt.assertErr(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestCache_Set(t *testing.T) {
tests := []struct {
name string
key string
value int
setup func(s *store.MockStoreInterface)
assertErr assert.ErrorAssertionFunc
}{
{
name: "happy",
key: "key",
value: 1,
setup: func(s *store.MockStoreInterface) {
s.EXPECT().Set(gomock.Any(), "key", 1).Return(nil)
},
assertErr: assert.NoError,
},
{
name: "unhappy",
key: "key",
value: 1,
setup: func(s *store.MockStoreInterface) {
s.EXPECT().Set(gomock.Any(), "key", 1).Return(errors.New("boom"))
},
assertErr: assert.Error,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()

gomockController := gomock.NewController(t)
mstore := store.NewMockStoreInterface(gomockController)

if tt.setup != nil {
tt.setup(mstore)
}

c := &cacheImpl[int]{cache: cache.New[int](mstore)}

err := c.Set(ctx, tt.key, tt.value)
tt.assertErr(t, err)
})
}
}
Loading

0 comments on commit 39318a6

Please sign in to comment.