Skip to content

Commit

Permalink
new: Add caching framework for static endpoint responses (linode#278)
Browse files Browse the repository at this point in the history
* Add client response cache

* Update cache stuff

* Disable exhaustive lint

* Cleanup

* Fix override logic

* Fix stuff

* Fix comment

* Address feedback

* Add additional endpoint

* Add short caching docs

* Change wording

* Update fixtures
  • Loading branch information
LBGarber committed Oct 13, 2022
1 parent 331b5db commit 92aa0a5
Show file tree
Hide file tree
Showing 24 changed files with 3,028 additions and 195 deletions.
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ linters:
- errorlint
- cyclop
- godot
- exhaustive
fast: false
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ linodes, err := linodego.ListInstances(context.Background(), linodego.NewListOpt
// err = nil
```

### Response Caching

By default, certain endpoints with static responses will be cached into memory.
Endpoints with cached responses are identified in their [accompanying documentation](https://pkg.go.dev/github.com/linode/linodego?utm_source=godoc).

The default cache entry expiry time is `15` minutes. Certain endpoints may override this value to allow for more frequent refreshes (e.g. `client.GetRegion(...)`).
The global cache expiry time can be customized using the `client.SetGlobalCacheExpiration(...)` method.

Response caching can be globally disabled or enabled for a client using the `client.UseCache(...)` method.

The global cache can be cleared and refreshed using the `client.InvalidateCache()` method.

### Writes

When performing a `POST` or `PUT` request, multiple field related errors will be returned as a single error, currently like:
Expand Down
142 changes: 142 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"net/url"
"os"
"path"
"reflect"
"strconv"
"sync"
"time"

"github.com/go-resty/resty/v2"
Expand Down Expand Up @@ -58,13 +60,27 @@ type Client struct {
loadedProfile string

configProfiles map[string]ConfigProfile

// Fields for caching endpoint responses
shouldCache bool
cacheExpiration time.Duration
cachedEntries map[string]clientCacheEntry
cachedEntryLock *sync.RWMutex
}

type EnvDefaults struct {
Token string
Profile string
}

type clientCacheEntry struct {
Created time.Time
Data any
// If != nil, use this instead of the
// global expiry
ExpiryOverride *time.Duration
}

type Request = resty.Request

func init() {
Expand Down Expand Up @@ -189,6 +205,127 @@ func (c *Client) addRetryConditional(retryConditional RetryConditional) *Client
return c
}

func (c *Client) addCachedResponse(endpoint string, response any, expiry *time.Duration) error {
if !c.shouldCache {
return nil
}

u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("failed to parse URL for caching: %s", err)
}

responseValue := reflect.ValueOf(response)

entry := clientCacheEntry{
Created: time.Now(),
ExpiryOverride: expiry,
}

switch responseValue.Kind() {
case reflect.Ptr:
// We want to automatically deref pointers to
// avoid caching mutable data.
entry.Data = responseValue.Elem().Interface()
default:
entry.Data = response
}

c.cachedEntryLock.Lock()
defer c.cachedEntryLock.Unlock()

c.cachedEntries[u.Path] = entry

return nil
}

func (c *Client) getCachedResponse(endpoint string) (any, error) {
if !c.shouldCache {
return nil, nil
}

u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse URL for caching: %s", err)
}

c.cachedEntryLock.RLock()

// Hacky logic to dynamically RUnlock
// only if it is still locked by the
// end of the function.
// This is necessary as we take write
// access if the entry has expired.
rLocked := true
defer func() {
if rLocked {
c.cachedEntryLock.RUnlock()
}
}()

entry, ok := c.cachedEntries[u.Path]
if !ok {
return nil, nil
}

// Handle expired entries
elapsedTime := time.Since(entry.Created)

hasExpired := elapsedTime > c.cacheExpiration
if entry.ExpiryOverride != nil {
hasExpired = elapsedTime > *entry.ExpiryOverride
}

if hasExpired {
// We need to give up our read access and request read-write access
c.cachedEntryLock.RUnlock()
rLocked = false

c.cachedEntryLock.Lock()
defer c.cachedEntryLock.Unlock()

delete(c.cachedEntries, u.Path)
return nil, nil
}

return c.cachedEntries[u.Path].Data, nil
}

// InvalidateCache clears all cached responses for all endpoints.
func (c *Client) InvalidateCache() {
c.cachedEntryLock.Lock()
defer c.cachedEntryLock.Unlock()

// GC will handle the old map
c.cachedEntries = make(map[string]clientCacheEntry)
}

// InvalidateCacheEndpoint invalidates a single cached endpoint.
func (c *Client) InvalidateCacheEndpoint(endpoint string) error {
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("failed to parse URL for caching: %s", err)
}

c.cachedEntryLock.Lock()
defer c.cachedEntryLock.Unlock()

delete(c.cachedEntries, u.Path)

return nil
}

// SetGlobalCacheExpiration sets the desired time for any cached response
// to be valid for.
func (c *Client) SetGlobalCacheExpiration(expiryTime time.Duration) {
c.cacheExpiration = expiryTime
}

// UseCache sets whether response caching should be used
func (c *Client) UseCache(value bool) {
c.shouldCache = value
}

// SetRetryMaxWaitTime sets the maximum delay before retrying a request.
func (c *Client) SetRetryMaxWaitTime(max time.Duration) *Client {
c.resty.SetRetryMaxWaitTime(max)
Expand Down Expand Up @@ -235,6 +372,11 @@ func NewClient(hc *http.Client) (client Client) {
client.resty = resty.New()
}

client.shouldCache = true
client.cacheExpiration = time.Minute * 15
client.cachedEntries = make(map[string]clientCacheEntry)
client.cachedEntryLock = &sync.RWMutex{}

client.SetUserAgent(DefaultUserAgent)

baseURL, baseURLExists := os.LookupEnv(APIHostVar)
Expand Down
52 changes: 48 additions & 4 deletions databases.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,50 +212,94 @@ func (c *Client) ListDatabases(ctx context.Context, opts *ListOptions) ([]Databa
return response.Data, nil
}

// ListDatabaseEngines lists all Database Engines
// ListDatabaseEngines lists all Database Engines. This endpoint is cached by default.
func (c *Client) ListDatabaseEngines(ctx context.Context, opts *ListOptions) ([]DatabaseEngine, error) {
response := DatabaseEnginesPagedResponse{}

if result, err := c.getCachedResponse(response.endpoint()); err != nil {
return nil, err
} else if result != nil {
return result.([]DatabaseEngine), nil
}

err := c.listHelper(ctx, &response, opts)
if err != nil {
return nil, err
}

if err := c.addCachedResponse(response.endpoint(), response.Data, &cacheExpiryTime); err != nil {
return nil, err
}

return response.Data, nil
}

// GetDatabaseEngine returns a specific Database Engine
// GetDatabaseEngine returns a specific Database Engine. This endpoint is cached by default.
func (c *Client) GetDatabaseEngine(ctx context.Context, opts *ListOptions, engineID string) (*DatabaseEngine, error) {
e := fmt.Sprintf("databases/engines/%s", engineID)

if result, err := c.getCachedResponse(e); err != nil {
return nil, err
} else if result != nil {
result := result.(DatabaseEngine)
return &result, nil
}

req := c.R(ctx).SetResult(&DatabaseEngine{})
r, err := coupleAPIErrors(req.Get(e))
if err != nil {
return nil, err
}

if err := c.addCachedResponse(e, r.Result(), &cacheExpiryTime); err != nil {
return nil, err
}

return r.Result().(*DatabaseEngine), nil
}

// ListDatabaseTypes lists all Types of Database provided in Linode Managed Databases
// ListDatabaseTypes lists all Types of Database provided in Linode Managed Databases. This endpoint is cached by default.
func (c *Client) ListDatabaseTypes(ctx context.Context, opts *ListOptions) ([]DatabaseType, error) {
response := DatabaseTypesPagedResponse{}

if result, err := c.getCachedResponse(response.endpoint()); err != nil {
return nil, err
} else if result != nil {
return result.([]DatabaseType), nil
}

err := c.listHelper(ctx, &response, opts)
if err != nil {
return nil, err
}

if err := c.addCachedResponse(response.endpoint(), response.Data, &cacheExpiryTime); err != nil {
return nil, err
}

return response.Data, nil
}

// GetDatabaseType returns a specific Database Type
// GetDatabaseType returns a specific Database Type. This endpoint is cached by default.
func (c *Client) GetDatabaseType(ctx context.Context, opts *ListOptions, typeID string) (*DatabaseType, error) {
e := fmt.Sprintf("databases/types/%s", typeID)

if result, err := c.getCachedResponse(e); err != nil {
return nil, err
} else if result != nil {
result := result.(DatabaseType)
return &result, nil
}

req := c.R(ctx).SetResult(&DatabaseType{})
r, err := coupleAPIErrors(req.Get(e))
if err != nil {
return nil, err
}

if err := c.addCachedResponse(e, r.Result(), &cacheExpiryTime); err != nil {
return nil, err
}

return r.Result().(*DatabaseType), nil
}
29 changes: 27 additions & 2 deletions kernels.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,48 @@ func (resp *LinodeKernelsPagedResponse) castResult(r *resty.Request, e string) (
return castedRes.Pages, castedRes.Results, nil
}

// ListKernels lists linode kernels
// ListKernels lists linode kernels. This endpoint is cached by default.
func (c *Client) ListKernels(ctx context.Context, opts *ListOptions) ([]LinodeKernel, error) {
response := LinodeKernelsPagedResponse{}

if result, err := c.getCachedResponse(response.endpoint()); err != nil {
return nil, err
} else if result != nil {
return result.([]LinodeKernel), nil
}

err := c.listHelper(ctx, &response, opts)
if err != nil {
return nil, err
}

if err := c.addCachedResponse(response.endpoint(), response.Data, nil); err != nil {
return nil, err
}

return response.Data, nil
}

// GetKernel gets the kernel with the provided ID
// GetKernel gets the kernel with the provided ID. This endpoint is cached by default.
func (c *Client) GetKernel(ctx context.Context, kernelID string) (*LinodeKernel, error) {
e := fmt.Sprintf("linode/kernels/%s", kernelID)

if result, err := c.getCachedResponse(e); err != nil {
return nil, err
} else if result != nil {
result := result.(LinodeKernel)
return &result, nil
}

req := c.R(ctx).SetResult(&LinodeKernel{})
r, err := coupleAPIErrors(req.Get(e))
if err != nil {
return nil, err
}

if err := c.addCachedResponse(e, r.Result(), nil); err != nil {
return nil, err
}

return r.Result().(*LinodeKernel), nil
}
Loading

0 comments on commit 92aa0a5

Please sign in to comment.