Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ type Cache interface {
// Put stores the []byte representation of a response in the cache with a key.
Put(string, []byte)

// Rm removes the cached response associated with the key.
Rm(string)
// Del removes the cached response associated with the key.
Del(string)
}

// CachedResponse returns the cached http.Response for the request if present and nil
Expand Down Expand Up @@ -54,7 +54,7 @@ func cacheKey(req *http.Request) string {
return req.Method + " " + req.URL.String()
}

// cacheKeyWithHeaders returns the cach key for a request and includes the specified
// cacheKeyWithHeaders returns the cache key for a request and includes the specified
// headers in their canonical form. This allows you to differentiate cache entries
// based on header values such as Authorization or custom headers.
func cacheKeyWithHeaders(req *http.Request, headers []string) string {
Expand Down
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ module go.rtnl.ai/httpcache

go 1.25.1

require github.com/stretchr/testify v1.11.1
require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/stretchr/testify v1.11.1
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
6 changes: 4 additions & 2 deletions inmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type InMemoryCache struct {
store map[string][]byte
}

var _ Cache = (*InMemoryCache)(nil)

// Get the []byte representation of the response and true if present.
func (c *InMemoryCache) Get(key string) (val []byte, ok bool) {
c.RLock()
Expand All @@ -28,8 +30,8 @@ func (c *InMemoryCache) Put(key string, val []byte) {
c.Unlock()
}

// Rm removes the cached response associated with the key.
func (c *InMemoryCache) Rm(key string) {
// Del removes the cached response associated with the key.
func (c *InMemoryCache) Del(key string) {
c.Lock()
delete(c.store, key)
c.Unlock()
Expand Down
90 changes: 90 additions & 0 deletions inmem_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package httpcache_test

import (
"testing"

"go.rtnl.ai/httpcache"
)

func benchmarkGet(size int) func(b *testing.B) {
return func(b *testing.B) {
cache := &httpcache.InMemoryCache{}
value := make([]byte, size)

// Prepopulate the cache
for i := 0; i < 128; i++ {
key := string(rune('a' + i))
cache.Put(key, value)
}

b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Get(string(rune('a' + i%192)))
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key generation pattern string(rune('a' + i%192)) will produce non-letter characters when the modulo exceeds 25 (e.g., when i=100, this gives rune 197 which is 'Å'). If the intent is to generate letter-based keys, consider using string(rune('a' + i%26)) to cycle through lowercase letters, or use a clearer pattern like fmt.Sprintf("key-%d", i%192) for numeric keys.

Copilot uses AI. Check for mistakes.
}
}
}

func BenchmarkInMemoryCacheGet(b *testing.B) {
b.Run("Small", benchmarkGet(512))
b.Run("Realistic", benchmarkGet(2048))
b.Run("Large", benchmarkGet(5.243e+6))
}

func benchmarkPut(size int) func(b *testing.B) {
return func(b *testing.B) {
cache := &httpcache.InMemoryCache{}
value := make([]byte, size)

b.ResetTimer()
for i := 0; i < b.N; i++ {
cache.Put(string(rune('a'+i%192)), value)
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key generation pattern string(rune('a'+i%192)) will produce non-letter characters when the modulo exceeds 25 (e.g., when i=100, this gives rune 197 which is 'Å'). If the intent is to generate letter-based keys, consider using string(rune('a' + i%26)) to cycle through lowercase letters, or use a clearer pattern like fmt.Sprintf("key-%d", i%192) for numeric keys.

Copilot uses AI. Check for mistakes.
}
}
}

func BenchmarkInMemoryCachePut(b *testing.B) {
b.Run("Small", benchmarkPut(512))
b.Run("Realistic", benchmarkPut(2048))
b.Run("Large", benchmarkPut(5.243e+6))
}

// Benchmark mixed operations
func BenchmarkInMemoryCacheMixed(b *testing.B) {
cache := &httpcache.InMemoryCache{}
value := make([]byte, 1024)

b.ResetTimer()
for i := 0; i < b.N; i++ {
key := string(rune('a' + i%128))
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key generation pattern string(rune('a' + i%128)) will produce non-letter characters when the modulo exceeds 25 (e.g., when i=100, this gives rune 197 which is 'Å'). If the intent is to generate letter-based keys, consider using string(rune('a' + i%26)) to cycle through lowercase letters, or use a clearer pattern like fmt.Sprintf("key-%d", i%128) for numeric keys.

Copilot uses AI. Check for mistakes.
switch i % 3 {
case 0:
cache.Put(key, value)
case 1:
cache.Get(key)
case 2:
cache.Del(key)
}
}
}

// Benchmark concurrent mixed operations
func BenchmarkInMemoryCacheParallelMixed(b *testing.B) {
cache := &httpcache.InMemoryCache{}
value := make([]byte, 1024)

b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := string(rune('a' + i%128))
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key generation pattern string(rune('a' + i%128)) will produce non-letter characters when the modulo exceeds 25 (e.g., when i=100, this gives rune 197 which is 'Å'). If the intent is to generate letter-based keys, consider using string(rune('a' + i%26)) to cycle through lowercase letters, or use a clearer pattern like fmt.Sprintf("key-%d", i%128) for numeric keys.

Copilot uses AI. Check for mistakes.
switch i % 3 {
case 0:
cache.Put(key, value)
case 1:
cache.Get(key)
case 2:
cache.Del(key)
}
i++
}
})
}
50 changes: 50 additions & 0 deletions inmem_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package httpcache_test

import (
"math/rand/v2"
"sync"
"testing"

"github.com/stretchr/testify/require"
"go.rtnl.ai/httpcache"
)

func TestInMemoryCache(t *testing.T) {
cache := &httpcache.InMemoryCache{}
cache.Put("foo", []byte("bar"))

val, ok := cache.Get("foo")
require.True(t, ok)
require.Equal(t, []byte("bar"), val)

cache.Del("foo")
_, ok = cache.Get("foo")
require.False(t, ok)
}

func TestInMemoryRace(t *testing.T) {
// Ensures no race conditions occur during concurrent access.
cache := &httpcache.InMemoryCache{}
value := make([]byte, 2048)

var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 512; j++ {
k := rand.IntN(64)
key := string(rune('a' + k%16))
switch k % 3 {
case 0:
cache.Put(key, value)
case 1:
cache.Get(key)
case 2:
cache.Del(key)
}
}
}()
}
wg.Wait()
}
128 changes: 128 additions & 0 deletions ristretto/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package ristretto

import "github.com/dgraph-io/ristretto/v2"

// Config is copied from ristretto.Config and uses the httpcache key and value types.
// It allows users to configure the Ristretto cache used by the Ristretto-backed with
// the documentation stored in httpcache rather than ristretto.
type Config struct {
// NumCounters determines the number of counters (keys) to keep that hold
// access frequency information. It's generally a good idea to have more
// counters than the max cache capacity, as this will improve eviction
// accuracy and subsequent hit ratios.
//
// For example, if you expect your cache to hold 1,000,000 items when full,
// NumCounters should be 10,000,000 (10x). Each counter takes up roughly
// 3 bytes (4 bits for each counter * 4 copies plus about a byte per
// counter for the bloom filter). Note that the number of counters is
// internally rounded up to the nearest power of 2, so the space usage
// may be a little larger than 3 bytes * NumCounters.
//
// We've seen good performance in setting this to 10x the number of items
// you expect to keep in the cache when full.
NumCounters int64

// MaxCost is how eviction decisions are made. For example, if MaxCost is
// 100 and a new item with a cost of 1 increases total cache cost to 101,
// 1 item will be evicted.
//
// MaxCost can be considered as the cache capacity, in whatever units you
// choose to use.
//
// For example, if you want the cache to have a max capacity of 100MB, you
// would set MaxCost to 100,000,000 and pass an item's number of bytes as
// the `cost` parameter for calls to Set. If new items are accepted, the
// eviction process will take care of making room for the new item and not
// overflowing the MaxCost value.
//
// MaxCost could be anything as long as it matches how you're using the cost
// values when calling Set.
MaxCost int64

// BufferItems determines the size of Get buffers.
//
// Unless you have a rare use case, using `64` as the BufferItems value
// results in good performance.
//
// If for some reason you see Get performance decreasing with lots of
// contention (you shouldn't), try increasing this value in increments of 64.
// This is a fine-tuning mechanism and you probably won't have to touch this.
BufferItems int64

// Metrics is true when you want variety of stats about the cache.
// There is some overhead to keeping statistics, so you should only set this
// flag to true when testing or throughput performance isn't a major factor.
Metrics bool

// OnEvict is called for every eviction with the evicted item.
OnEvict func(item *ristretto.Item[[]byte])

// OnReject is called for every rejection done via the policy.
OnReject func(item *ristretto.Item[[]byte])

// OnExit is called whenever a value is removed from cache. This can be
// used to do manual memory deallocation. Would also be called on eviction
// as well as on rejection of the value.
OnExit func(val []byte)

// ShouldUpdate is called when a value already exists in cache and is being updated.
// If ShouldUpdate returns true, the cache continues with the update (Set). If the
// function returns false, no changes are made in the cache. If the value doesn't
// already exist, the cache continue with setting that value for the given key.
//
// In this function, you can check whether the new value is valid. For example, if
// your value has timestamp associated with it, you could check whether the new
// value has the latest timestamp, preventing you from setting an older value.
ShouldUpdate func(cur, prev []byte) bool

// KeyToHash function is used to customize the key hashing algorithm.
// Each key will be hashed using the provided function. If keyToHash value
// is not set, the default keyToHash function is used.
//
// Ristretto has a variety of defaults depending on the underlying interface type
// https://github.com/dgraph-io/ristretto/blob/main/z/z.go#L19-L41).
//
// Note that if you want 128bit hashes you should use the both the values
// in the return of the function. If you want to use 64bit hashes, you can
// just return the first uint64 and return 0 for the second uint64.
KeyToHash func(key string) (uint64, uint64)

// Cost evaluates a value and outputs a corresponding cost. This function is ran
// after Set is called for a new item or an item is updated with a cost param of 0.
//
// Cost is an optional function you can pass to the Config in order to evaluate
// item cost at runtime, and only when the Set call isn't going to be dropped. This
// is useful if calculating item cost is particularly expensive and you don't want to
// waste time on items that will be dropped anyways.
//
// To signal to Ristretto that you'd like to use this Cost function:
// 1. Set the Cost field to a non-nil function.
// 2. When calling Set for new items or item updates, use a `cost` of 0.
Cost func(value []byte) int64

// IgnoreInternalCost set to true indicates to the cache that the cost of
// internally storing the value should be ignored. This is useful when the
// cost passed to set is not using bytes as units. Keep in mind that setting
// this to true will increase the memory usage.
IgnoreInternalCost bool

// TtlTickerDurationInSec sets the value of time ticker for cleanup keys on TTL expiry.
TtlTickerDurationInSec int64
}

func (c *Config) convert() *ristretto.Config[string, []byte] {
return &ristretto.Config[string, []byte]{
NumCounters: c.NumCounters,
MaxCost: c.MaxCost,
BufferItems: c.BufferItems,
Metrics: c.Metrics,
OnEvict: c.OnEvict,
OnReject: c.OnReject,
OnExit: c.OnExit,
ShouldUpdate: c.ShouldUpdate,
KeyToHash: c.KeyToHash,
Cost: c.Cost,
IgnoreInternalCost: c.IgnoreInternalCost,
TtlTickerDurationInSec: c.TtlTickerDurationInSec,
}
}
Loading
Loading