Skip to content

Commit

Permalink
docs: Document non-existent record storage
Browse files Browse the repository at this point in the history
  • Loading branch information
viccon committed Apr 7, 2024
1 parent a88a240 commit c1d3bc9
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 14 deletions.
108 changes: 107 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ has just expired or been evicted from the cache) come in at once. This can
cause all requests to fetch the data concurrently, subsequently causing a
significant load on the underlying data source.

To prevent this, we can enable stampede protection:
To prevent this, we can enable **stampede protection**:

```go
func main() {
Expand Down Expand Up @@ -164,3 +164,109 @@ go run .
```

The entire example is available [here.](https://github.com/creativecreature/sturdyc/tree/main/examples/stampede)

# Non-existent keys
Another factor to consider is non-existent keys. It could be an ID that has
been added manually to a CMS with a typo that leads to no data being returned
from the upstream source. This can significantly increase our systems latency,
as we're never able to get a cache hit and serve from memory.

However, it could also be caused by a slow ingestion process. Perhaps it takes
some time for a new entity to propagate through a distributed system.

The cache allows us to store these IDs as missing records, while refreshing
them like any other record. To illustrate, we can extend the previous example
to enable this functionality:

```go
func main() {
// ...

// Tell the cache to store missing records.
storeMisses := true

// Create a cache client with the specified configuration.
cacheClient := sturdyc.New(capacity, numShards, ttl, evictionPercentage,
sturdyc.WithStampedeProtection(minRefreshDelay, maxRefreshDelay, retryBaseDelay, storeMisses),
)

// Create a new API instance with the cache client.
api := NewAPI(cacheClient)

// ...
for i := 0; i < 100; i++ {
val, err := api.Get(context.Background(), "key")
if errors.Is(err, sturdyc.ErrMissingRecord) {
log.Println("Record does not exist.")
}
if err == nil {
log.Printf("Value: %s\n", val)
}
time.Sleep(minRefreshDelay)
}
}
```

Next, we'll modify the API client to return the `ErrStoreMissingRecord` error
for the first *3* calls. This error instructs the cache to store it as a missing
record:

```go
type API struct {
count int
cacheClient *sturdyc.Client
}

func NewAPI(c *sturdyc.Client) *API {
return &API{
count: 0,
cacheClient: c,
}
}

func (a *API) Get(ctx context.Context, key string) (string, error) {
fetchFn := func(_ context.Context) (string, error) {
log.Printf("Fetching value for key: %s\n", key)
a.count++
if a.count < 3 {
return "", sturdyc.ErrStoreMissingRecord
}
return "value", nil
}
return sturdyc.GetFetch(ctx, a.cacheClient, key, fetchFn)
}
```

and then call it:

```go
func main() {
// ...

for i := 0; i < 100; i++ {
val, err := api.Get(context.Background(), "key")
if errors.Is(err, sturdyc.ErrMissingRecord) {
log.Println("Record does not exist.")
}
if err == nil {
log.Printf("Value: %s\n", val)
}
time.Sleep(minRefreshDelay)
}
}
```

```sh
2024/04/07 09:42:49 Fetching value for key: key
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Fetching value for key: key
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Record does not exist.
2024/04/07 09:42:49 Fetching value for key: key
2024/04/07 09:42:49 Value: value
2024/04/07 09:42:49 Value: value
2024/04/07 09:42:49 Fetching value for key: key
```
2 changes: 1 addition & 1 deletion cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func GetFetch[T any](ctx context.Context, client *Client, key string, fetchFn Fe
}

if shouldIgnore {
return value, ErrMissingRecordCooldown
return value, ErrMissingRecord
}

// If we don't have this item in our cache, we'll fetch it
Expand Down
2 changes: 1 addition & 1 deletion cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ func TestGetFetchMissingRecord(t *testing.T) {
clock.Add(maxRefreshDelay * 1)
fetchObserver.Response("1")
_, err = sturdyc.GetFetch(ctx, c, "1", fetchObserver.Fetch)
if !errors.Is(err, sturdyc.ErrMissingRecordCooldown) {
if !errors.Is(err, sturdyc.ErrMissingRecord) {
t.Fatalf("expected ErrMissingRecordCooldown, got %v", err)
}
<-fetchObserver.FetchCompleted
Expand Down
16 changes: 8 additions & 8 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ var (
// FetchFn, for the BatchFetchFn you should enable the functionality through
// options, and simply return a map without the missing record being present.
ErrStoreMissingRecord = errors.New("record not found")
// ErrMissingRecordCooldown is returned by cache.Get when a record has been fetched
// unsuccessfully before, and is currently in cooldown.
ErrMissingRecordCooldown = errors.New("record is currently in cooldown")
// ErrOnlyCachedRecords is returned by cache.BatchGet when we have some of the requested
// records in the cache, but the call to fetch the remaining records failed. The consumer
// can then choose if they want to proceed with the cached records or retry the operation.
// ErrMissingRecord is returned by sturdyc.GetFetch when a record has been fetched unsuccessfully.
ErrMissingRecord = errors.New("record is missing")
// ErrOnlyCachedRecords is returned by sturdyc.GetFetchBatch when we have
// some of the requested records in the cache, but the call to fetch the
// remaining records failed. The consumer can then choose if they want to
// proceed with the cached records or retry the operation.
ErrOnlyCachedRecords = errors.New("failed to fetch the records that we did not have cached")
)

func ErrIsStoreMissingRecordOrMissingRecordCooldown(err error) bool {
func ErrIsStoreMissingRecordOrMissingRecord(err error) bool {
if err == nil {
return false
}
return errors.Is(err, ErrStoreMissingRecord) || errors.Is(err, ErrMissingRecordCooldown)
return errors.Is(err, ErrStoreMissingRecord) || errors.Is(err, ErrMissingRecord)
}
3 changes: 0 additions & 3 deletions examples/misses/main.go

This file was deleted.

85 changes: 85 additions & 0 deletions examples/missing/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"context"
"errors"
"log"
"time"

"github.com/creativecreature/sturdyc"
)

type API struct {
count int
cacheClient *sturdyc.Client
}

func NewAPI(c *sturdyc.Client) *API {
return &API{
count: 0,
cacheClient: c,
}
}

func (a *API) Get(ctx context.Context, key string) (string, error) {
fetchFn := func(_ context.Context) (string, error) {
log.Printf("Fetching value for key: %s\n", key)
a.count++
if a.count < 3 {
return "", sturdyc.ErrStoreMissingRecord
}
return "value", nil
}
return sturdyc.GetFetch(ctx, a.cacheClient, key, fetchFn)
}

func main() {
// ===========================================================
// ===================== Basic configuration =================
// ===========================================================
// Maximum number of entries in the sturdyc.
capacity := 10000
// Number of shards to use for the sturdyc.
numShards := 10
// Time-to-live for cache entries.
ttl := 2 * time.Hour
// Percentage of entries to evict when the cache is full. Setting this
// to 0 will make set a no-op if the cache has reached its capacity.
evictionPercentage := 10

// ===========================================================
// =================== Stampede protection ===================
// ===========================================================
// Set a minimum and maximum refresh delay for the sturdyc. This is
// used to spread out the refreshes for entries evenly over time.
minRefreshDelay := time.Millisecond * 10
maxRefreshDelay := time.Millisecond * 30
// The base for exponential backoff when retrying a refresh.
retryBaseDelay := time.Millisecond * 10
// Tell the cache to store missing records.
storeMisses := true

// Create a cache client with the specified configuration.
cacheClient := sturdyc.New(capacity, numShards, ttl, evictionPercentage,
sturdyc.WithStampedeProtection(minRefreshDelay, maxRefreshDelay, retryBaseDelay, storeMisses),
)

// Create a new API instance with the cache client.
api := NewAPI(cacheClient)

// We are going to retrieve the values every 10 milliseconds, however the
// logs will reveal that actual refreshes fluctuate randomly within a 10-30
// millisecond range. Even if this loop is executed across multiple
// goroutines, the API call frequency will maintain this variability,
// ensuring we avoid overloading the API with requests.
for i := 0; i < 100; i++ {
val, err := api.Get(context.Background(), "key")
if errors.Is(err, sturdyc.ErrMissingRecord) {
log.Println("Record does not exist.")
}
if err == nil {
log.Printf("Value: %s\n", val)
}
time.Sleep(minRefreshDelay)
}
}

0 comments on commit c1d3bc9

Please sign in to comment.