Files
releases/v1/topdown/cache/cache_test.go
T
Charlie Egan 11e52c4df6 v1/plugins: Address race in config access (#7825)
* v1/plugins: Address race in config access

I ran into this race condition on another PR:

https://github.com/open-policy-agent/opa/actions/runs/16655603110/job/47139789057

I have tried to make all manager.Config access thread-safe by adding new
getters for used values. GetConfig is regrettably based on a JSON
roundtrip deep copy of the config. This us used in tests (fine) but also
in the discovery plugin:

https://github.com/open-policy-agent/opa/blob/2d014a89bbbc307d7204817220146ffae992e838/v1/plugins/discovery/discovery.go#L122

getPluginSet is very tightly coupled to the manager.Config and because
of it's dependencies on status and the other plugins packages, it's hard
to break out.

So, for now, I think this is an improvement and worth getting a second
opinion on before more refactoring.

Signed-off-by: Charlie Egan <charlie@styra.com>

* v1/config: Use add Clone to config

This makes the use of the manager's config more thread-safe and
consistent without more API changes.

Signed-off-by: Charlie Egan <charlie@styra.com>

* topdown: Add clone() funcs for config structs

NamedValueCacheConfig.Clone, InterQueryBuiltinValueCacheConfig.Clone and
InterQueryBuiltinCacheConfig.Clone have been added.

All Clone methods return a deep copy of the struct. This is tested for
missed new fields using PopulateAllFields, a generic function that
stuffs structs with values for all fields.

Signed-off-by: Charlie Egan <charlie@styra.com>

* plugins: Clone new config

Signed-off-by: Charlie Egan <charlie@styra.com>

---------

Signed-off-by: Charlie Egan <charlie@styra.com>
2025-08-13 12:24:16 +00:00

886 lines
27 KiB
Go

// Copyright 2020 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package cache
import (
"context"
"reflect"
"sync"
"testing"
"time"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/util/test"
)
func TestParseCachingConfig(t *testing.T) {
t.Parallel()
maxSize := new(int64)
*maxSize = defaultMaxSizeBytes
period := new(int64)
*period = defaultStaleEntryEvictionPeriodSeconds
threshold := new(int64)
*threshold = defaultForcedEvictionThresholdPercentage
maxNumEntriesInterQueryValueCache := new(int)
*maxNumEntriesInterQueryValueCache = defaultInterQueryBuiltinValueCacheSize
expected := &Config{
InterQueryBuiltinCache: InterQueryBuiltinCacheConfig{
MaxSizeBytes: maxSize,
StaleEntryEvictionPeriodSeconds: period,
ForcedEvictionThresholdPercentage: threshold,
},
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{
MaxNumEntries: maxNumEntriesInterQueryValueCache,
},
}
tests := map[string]struct {
input []byte
wantErr bool
}{
"empty_config": {
input: nil,
wantErr: false,
},
"default_limit": {
input: []byte(`{"inter_query_builtin_cache": {},}`),
wantErr: false,
},
"default_num_entries": {
input: []byte(`{"inter_query_builtin_value_cache": {},}`),
wantErr: false,
},
"bad_limit": {
input: []byte(`{"inter_query_builtin_cache": {"max_size_bytes": "100"},}`),
wantErr: true,
},
"bad_num_entries": {
input: []byte(`{"inter_query_builtin_value_cache": {"max_num_entries": "100"},}`),
wantErr: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
config, err := ParseCachingConfig(tc.input)
if tc.wantErr {
if err == nil {
t.Fatal("Expected error but got nil")
}
} else {
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
}
if !tc.wantErr && !reflect.DeepEqual(config, expected) {
t.Fatalf("want %v got %v", expected, config)
}
})
}
// cache limit specified
in := `{"inter_query_builtin_cache": {"max_size_bytes": 100},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
limit := int64(100)
expected.InterQueryBuiltinCache.MaxSizeBytes = &limit
if !reflect.DeepEqual(config, expected) {
t.Fatalf("want %v got %v", expected, config)
}
}
func TestInterValueCache_DefaultConfiguration(t *testing.T) {
t.Run("default config not set", func(t *testing.T) {
config := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{},
}
c := NewInterQueryValueCache(context.Background(), &config)
if c.GetCache("foo") != nil {
t.Fatal("Expected cache to be disabled")
}
})
t.Run("default config set", func(t *testing.T) {
config := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{},
}
RegisterDefaultInterQueryBuiltinValueCacheConfig("bar", &NamedValueCacheConfig{
MaxNumEntries: &[]int{5}[0],
})
c := NewInterQueryValueCache(context.Background(), &config)
if act := *c.GetCache("bar").(*interQueryValueCacheBucket).config.MaxNumEntries; act != 5 {
t.Fatalf("Expected 5 max entries, got %d", act)
}
})
t.Run("explicitly disabled", func(t *testing.T) {
cacheConfig := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{
NamedCacheConfigs: map[string]*NamedValueCacheConfig{
"baz": nil,
},
},
}
RegisterDefaultInterQueryBuiltinValueCacheConfig("baz", nil)
c := NewInterQueryValueCache(context.Background(), &cacheConfig)
if c.GetCache("baz") != nil {
t.Fatal("Expected cache to be disabled")
}
})
t.Run("override", func(t *testing.T) {
cacheConfig := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{
NamedCacheConfigs: map[string]*NamedValueCacheConfig{
"box": {
MaxNumEntries: &[]int{5}[0],
},
},
},
}
RegisterDefaultInterQueryBuiltinValueCacheConfig("box", &NamedValueCacheConfig{
MaxNumEntries: &[]int{10}[0],
})
c := NewInterQueryValueCache(context.Background(), &cacheConfig)
if act := *c.GetCache("box").(*interQueryValueCacheBucket).config.MaxNumEntries; act != 5 {
t.Fatalf("Expected 5 max entries, got %d", act)
}
})
}
func TestInterValueCache_NamedCaches(t *testing.T) {
t.Parallel()
t.Run("configured max is respected", func(t *testing.T) {
config := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{
NamedCacheConfigs: map[string]*NamedValueCacheConfig{
"foo": {
MaxNumEntries: &[]int{2}[0],
},
},
},
}
c := NewInterQueryValueCache(context.Background(), &config)
nc := c.GetCache("foo").(*interQueryValueCacheBucket)
if act := *nc.config.MaxNumEntries; act != 2 {
t.Fatalf("Expected 2 max entries, got %d", act)
}
if nc.items.Len() != 0 {
t.Fatalf("Expected cache to be empty")
}
nc.Insert(ast.StringTerm("a").Value, "b")
if nc.items.Len() != 1 {
t.Fatalf("Expected cache to have 1 entry")
}
if v, found := nc.Get(ast.StringTerm("a").Value); !found && v != "b" {
t.Fatalf("Expected cache hit")
}
nc.Insert(ast.StringTerm("c").Value, "d")
if nc.items.Len() != 2 {
t.Fatalf("Expected cache to have 2 entries")
}
if v, found := nc.Get(ast.StringTerm("c").Value); !found && v != "d" {
t.Fatalf("Expected cache hit")
}
nc.Insert(ast.StringTerm("e").Value, "f")
if nc.items.Len() != 2 {
t.Fatalf("Expected cache to still have 2 entries")
}
if v, found := nc.Get(ast.StringTerm("e").Value); !found && v != "f" {
t.Fatalf("Expected cache hit")
}
})
t.Run("named caches are separate", func(t *testing.T) {
config := Config{
InterQueryBuiltinValueCache: InterQueryBuiltinValueCacheConfig{
MaxNumEntries: &[]int{2}[0],
NamedCacheConfigs: map[string]*NamedValueCacheConfig{
"foo": {
MaxNumEntries: &[]int{2}[0],
},
"bar": {
MaxNumEntries: &[]int{2}[0],
},
},
},
}
c := NewInterQueryValueCache(context.Background(), &config)
c.Insert(ast.StringTerm("foo").Value, "bar")
nc1 := c.GetCache("foo").(*interQueryValueCacheBucket)
nc2 := c.GetCache("bar").(*interQueryValueCacheBucket)
nc1.Insert(ast.StringTerm("a").Value, "b")
nc2.Insert(ast.StringTerm("c").Value, "d")
if _, found := c.Get(ast.StringTerm("foo").Value); !found {
t.Fatal("Expected cache hit")
}
if _, found := c.Get(ast.StringTerm("a").Value); found {
t.Fatal("Expected cache miss")
}
if _, found := c.Get(ast.StringTerm("c").Value); found {
t.Fatal("Expected cache miss")
}
if _, found := nc1.Get(ast.StringTerm("a").Value); !found {
t.Fatal("Expected cache hit")
}
if _, found := nc1.Get(ast.StringTerm("c").Value); found {
t.Fatal("Expected cache miss")
}
if _, found := nc1.Get(ast.StringTerm("foo").Value); found {
t.Fatal("Expected cache miss")
}
if _, found := nc2.Get(ast.StringTerm("c").Value); !found {
t.Fatal("Expected cache hit")
}
if _, found := nc2.Get(ast.StringTerm("a").Value); found {
t.Fatal("Expected cache miss")
}
if _, found := nc2.Get(ast.StringTerm("foo").Value); found {
t.Fatal("Expected cache miss")
}
})
}
func TestInsert(t *testing.T) {
t.Parallel()
in := `{"inter_query_builtin_cache": {"max_size_bytes": 20},}` // 20 byte limit for test purposes
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache := NewInterQueryCache(config)
// large cache value that exceeds limit
cacheValueLarge := newInterQueryCacheValue(ast.StringTerm("bar").Value, 40)
dropped := cache.Insert(ast.StringTerm("foo").Value, cacheValueLarge)
if dropped != 1 {
t.Fatal("Expected dropped to be one")
}
_, found := cache.Get(ast.StringTerm("foo").Value)
if found {
t.Fatal("Unexpected key \"foo\" in cache")
}
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
dropped = cache.Insert(ast.StringTerm("foo").Value, cacheValue)
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
// exceed cache limit
cacheValue2 := newInterQueryCacheValue(ast.StringTerm("bar2").Value, 20)
dropped = cache.Insert(ast.StringTerm("foo2").Value, cacheValue2)
if dropped != 1 {
t.Fatal("Expected dropped to be one")
}
_, found = cache.Get(ast.StringTerm("foo2").Value)
if !found {
t.Fatal("Expected key \"foo2\" in cache")
}
_, found = cache.Get(ast.StringTerm("foo").Value)
if found {
t.Fatal("Unexpected key \"foo\" in cache")
}
cacheValue3 := newInterQueryCacheValue(ast.StringTerm("bar3").Value, 10)
cache.Insert(ast.StringTerm("foo3").Value, cacheValue3)
cacheValue4 := newInterQueryCacheValue(ast.StringTerm("bar4").Value, 10)
cache.Insert(ast.StringTerm("foo4").Value, cacheValue4)
cacheValue5 := newInterQueryCacheValue(ast.StringTerm("bar5").Value, 20)
dropped = cache.Insert(ast.StringTerm("foo5").Value, cacheValue5)
if dropped != 2 {
t.Fatal("Expected dropped to be two")
}
_, found = cache.Get(ast.StringTerm("foo3").Value)
if found {
t.Fatal("Unexpected key \"foo3\" in cache")
}
_, found = cache.Get(ast.StringTerm("foo4").Value)
if found {
t.Fatal("Unexpected key \"foo4\" in cache")
}
_, found = cache.Get(ast.StringTerm("foo5").Value)
if !found {
t.Fatal("Expected key \"foo5\" in cache")
}
verifyCacheList(t, cache)
// replacing an existing key should not affect cache size
cache = NewInterQueryCache(config)
cacheValue6 := newInterQueryCacheValue(ast.String("bar6"), 10)
cache.Insert(ast.String("foo6"), cacheValue6)
cache.Insert(ast.String("foo6"), cacheValue6)
verifyCacheList(t, cache)
cacheValue7 := newInterQueryCacheValue(ast.String("bar7"), 10)
dropped = cache.Insert(ast.StringTerm("foo7").Value, cacheValue7)
verifyCacheList(t, cache)
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
}
func TestInterQueryValueCache(t *testing.T) {
t.Parallel()
in := `{"inter_query_builtin_value_cache": {"max_num_entries": 4},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache := NewInterQueryValueCache(context.Background(), config)
cache.Insert(ast.StringTerm("foo").Value, "bar")
cache.Insert(ast.StringTerm("foo2").Value, "bar2")
cache.Insert(ast.StringTerm("hello").Value, "world")
dropped := cache.Insert(ast.StringTerm("hello2").Value, "world2")
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
value, found := cache.Get(ast.StringTerm("foo").Value)
if !found {
t.Fatal("Expected key \"foo\" in cache")
}
actual, ok := value.(string)
if !ok {
t.Fatal("Expected string value")
}
if actual != "bar" {
t.Fatalf("Expected value \"bar\" but got %v", actual)
}
dropped = cache.Insert(ast.StringTerm("foo3").Value, "bar3")
if dropped != 1 {
t.Fatal("Expected dropped to be one")
}
_, found = cache.Get(ast.StringTerm("foo3").Value)
if !found {
t.Fatal("Expected key \"foo3\" in cache")
}
// update the cache config
in = `{"inter_query_builtin_value_cache": {"max_num_entries": 0},}` // unlimited
config, err = ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache.UpdateConfig(config)
cache.Insert(ast.StringTerm("a").Value, "b")
cache.Insert(ast.StringTerm("c").Value, "d")
cache.Insert(ast.StringTerm("e").Value, "f")
dropped = cache.Insert(ast.StringTerm("g").Value, "h")
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
// at this point the cache should have 8 entries
// update the cache size and verify multiple items dropped
in = `{"inter_query_builtin_value_cache": {"max_num_entries": 6},}`
config, err = ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache.UpdateConfig(config)
dropped = cache.Insert(ast.StringTerm("i").Value, "j")
if dropped != 3 {
t.Fatal("Expected dropped to be three")
}
_, found = cache.Get(ast.StringTerm("i").Value)
if !found {
t.Fatal("Expected key \"i\" in cache")
}
cache.Delete(ast.StringTerm("i").Value)
_, found = cache.Get(ast.StringTerm("i").Value)
if found {
t.Fatal("Unexpected key \"i\" in cache")
}
}
func TestConcurrentInsert(t *testing.T) {
t.Parallel()
in := `{"inter_query_builtin_cache": {"max_size_bytes": 20},}` // 20 byte limit for test purposes
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache := NewInterQueryCache(config)
cacheValue := newInterQueryCacheValue(ast.String("bar"), 10)
cache.Insert(ast.String("foo"), cacheValue)
wg := sync.WaitGroup{}
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
cacheValue2 := newInterQueryCacheValue(ast.String("bar2"), 5)
cache.Insert(ast.String("foo2"), cacheValue2)
}()
}
wg.Wait()
cacheValue3 := newInterQueryCacheValue(ast.String("bar3"), 5)
dropped := cache.Insert(ast.String("foo3"), cacheValue3)
verifyCacheList(t, cache)
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
_, found := cache.Get(ast.String("foo"))
if !found {
t.Fatal("Expected key \"foo\" in cache")
}
_, found = cache.Get(ast.String("foo2"))
if !found {
t.Fatal("Expected key \"foo2\" in cache")
}
_, found = cache.Get(ast.String("foo3"))
if !found {
t.Fatal("Expected key \"foo3\" in cache")
}
}
func TestClone(t *testing.T) {
t.Parallel()
in := `{"inter_query_builtin_cache": {"max_size_bytes": 40},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache := NewInterQueryCache(config)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
dropped := cache.Insert(ast.StringTerm("foo").Value, cacheValue)
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
val, found := cache.Get(ast.StringTerm("foo").Value)
if !found {
t.Fatal("Expected key \"foo\" in cache")
}
dup, err := cache.Clone(val)
if err != nil {
t.Fatal(err)
}
original, ok := val.(*testInterQueryCacheValue)
if !ok {
t.Fatal("unexpected type")
}
cloned, ok := dup.(*testInterQueryCacheValue)
if !ok {
t.Fatal("unexpected type")
}
if !reflect.DeepEqual(*original, *cloned) {
t.Fatalf("Expected to get %v, but got %v", *original, *cloned)
}
}
func TestDelete(t *testing.T) {
t.Parallel()
config, err := ParseCachingConfig(nil)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
cache := NewInterQueryCache(config)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
dropped := cache.Insert(ast.StringTerm("foo").Value, cacheValue)
if dropped != 0 {
t.Fatal("Expected dropped to be zero")
}
verifyCacheList(t, cache)
cache.Delete(ast.StringTerm("foo").Value)
_, found := cache.Get(ast.StringTerm("foo").Value)
if found {
t.Fatal("Unexpected key \"foo\" in cache")
}
verifyCacheList(t, cache)
}
func TestInsertWithExpiryAndEviction(t *testing.T) {
t.Parallel()
// 50 byte max size
// 1s stale cleanup period
// 80% threshold to for FIFO eviction (eviction after 40 bytes)
in := `{"inter_query_builtin_cache": {"max_size_bytes": 50, "stale_entry_eviction_period_seconds": 1, "forced_eviction_threshold_percentage": 80},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// This starts a background ticker at stale_entry_eviction_period_seconds to clean up items.
ctx, cancel := context.WithCancel(context.Background())
cache := NewInterQueryCacheWithContext(ctx, config)
t.Cleanup(cancel)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
cache.InsertWithExpiry(ast.StringTerm("force_evicted_foo").Value, cacheValue, time.Now().Add(100*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("force_evicted_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found %v", cacheValue, fetchedCacheValue)
}
cache.InsertWithExpiry(ast.StringTerm("expired_foo").Value, cacheValue, time.Now().Add(900*time.Millisecond))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found %v", cacheValue, fetchedCacheValue)
}
cache.InsertWithExpiry(ast.StringTerm("foo").Value, cacheValue, time.Now().Add(10*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found %v", cacheValue, fetchedCacheValue)
}
// Ensure stale entries clean up routine runs at least once
time.Sleep(1100 * time.Millisecond)
// Entry deleted even though not expired because force evicted when foo is inserted
if fetchedCacheValue, found := cache.Get(ast.StringTerm("force_evicted_foo").Value); found {
t.Fatalf("Didn't expect cache entry for force_evicted_foo, found entry with value %v", fetchedCacheValue)
}
// Stale clean up routine runs and deletes expired entry
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); found {
t.Fatalf("Didn't expect cache entry for expired_foo, found entry with value %v", fetchedCacheValue)
}
// Stale clean up routine runs but doesn't delete the entry
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found %v", cacheValue, fetchedCacheValue)
}
}
func TestInsertHighTTLWithStaleEntryCleanup(t *testing.T) {
t.Parallel()
// 40 byte max size
// 1s stale cleanup period
// 100% threshold to for FIFO eviction (eviction after 40 bytes)
in := `{"inter_query_builtin_cache": {"max_size_bytes": 40, "stale_entry_eviction_period_seconds": 1, "forced_eviction_threshold_percentage": 100},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// This starts a background ticker at stale_entry_eviction_period_seconds to clean up items.
ctx, cancel := context.WithCancel(context.Background())
cache := NewInterQueryCacheWithContext(ctx, config)
t.Cleanup(cancel)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
cache.InsertWithExpiry(ast.StringTerm("high_ttl_foo").Value, cacheValue, time.Now().Add(100*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("high_ttl_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found %v", cacheValue, fetchedCacheValue)
}
cache.InsertWithExpiry(ast.StringTerm("expired_foo").Value, cacheValue, time.Now().Add(900*time.Millisecond))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found no entry", fetchedCacheValue)
}
// Ensure stale entries clean up routine runs at least once
time.Sleep(1100 * time.Millisecond)
cache.InsertWithExpiry(ast.StringTerm("foo").Value, cacheValue, time.Now().Add(10*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v, found %v", cacheValue, fetchedCacheValue)
}
// Since expired_foo is deleted by stale cleanup routine, high_ttl_foo is not evicted when foo is inserted
if fetchedCacheValue, found := cache.Get(ast.StringTerm("high_ttl_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for high_ttl_foo, found %v", cacheValue, fetchedCacheValue)
}
// Stale clean up routine runs and deletes expired entry
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); found {
t.Fatalf("Didn't expect cache entry for expired_foo, found entry with value %v", fetchedCacheValue)
}
}
func TestInsertHighTTLWithoutStaleEntryCleanup(t *testing.T) {
t.Parallel()
// 40 byte max size
// 0s stale cleanup period -> no cleanup
// 100% threshold to for FIFO eviction (eviction after 40 bytes)
in := `{"inter_query_builtin_cache": {"max_size_bytes": 40, "stale_entry_eviction_period_seconds": 0, "forced_eviction_threshold_percentage": 100},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// This starts a background ticker at stale_entry_eviction_period_seconds to clean up items.
ctx, cancel := context.WithCancel(context.Background())
cache := NewInterQueryCacheWithContext(ctx, config)
t.Cleanup(cancel)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
cache.InsertWithExpiry(ast.StringTerm("high_ttl_foo").Value, cacheValue, time.Now().Add(100*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("high_ttl_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for high_ttl_foo, found no entry", fetchedCacheValue)
}
cache.InsertWithExpiry(ast.StringTerm("expired_foo").Value, cacheValue, time.Now().Add(1*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for expired_foo, found no entry", fetchedCacheValue)
}
cache.InsertWithExpiry(ast.StringTerm("foo").Value, cacheValue, time.Now().Add(10*time.Second))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found no entry", fetchedCacheValue)
}
// Since stale cleanup routine is disabled, high_ttl_foo is evicted when foo is inserted
if fetchedCacheValue, found := cache.Get(ast.StringTerm("high_ttl_foo").Value); found {
t.Fatalf("Didn't expect cache entry for high_ttl_foo, found entry with value %v", fetchedCacheValue)
}
// Stale clean up disabled so expired entry exists
if fetchedCacheValue, found := cache.Get(ast.StringTerm("expired_foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for expired_foo, found %v", cacheValue, fetchedCacheValue)
}
}
func TestZeroExpiryTime(t *testing.T) {
t.Parallel()
// 20 byte max size
// 1s stale cleanup period
// 100% threshold to for FIFO eviction (eviction after 40 bytes)
in := `{"inter_query_builtin_cache": {"max_size_bytes": 20, "stale_entry_eviction_period_seconds": 1, "forced_eviction_threshold_percentage": 100},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// This starts a background ticker at stale_entry_eviction_period_seconds to clean up items.
ctx, cancel := context.WithCancel(context.Background())
cache := NewInterQueryCacheWithContext(ctx, config)
t.Cleanup(cancel)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
cache.InsertWithExpiry(ast.StringTerm("foo").Value, cacheValue, time.Time{})
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found %v", cacheValue, fetchedCacheValue)
}
time.Sleep(1100 * time.Millisecond)
// Stale entry cleanup routine skips zero time cache entries
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found %v", cacheValue, fetchedCacheValue)
}
}
func TestCancelNewInterQueryCacheWithContext(t *testing.T) {
t.Parallel()
// 40 byte max size
// 1s stale cleanup period
// 100% threshold to for FIFO eviction (eviction after 40 bytes)
in := `{"inter_query_builtin_cache": {"max_size_bytes": 40, "stale_entry_eviction_period_seconds": 1, "forced_eviction_threshold_percentage": 100},}`
config, err := ParseCachingConfig([]byte(in))
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cache := NewInterQueryCacheWithContext(ctx, config)
cacheValue := newInterQueryCacheValue(ast.StringTerm("bar").Value, 20)
cache.InsertWithExpiry(ast.StringTerm("foo").Value, cacheValue, time.Now().Add(100*time.Millisecond))
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found %v", cacheValue, fetchedCacheValue)
}
cancel()
time.Sleep(1100 * time.Millisecond)
// Stale entry cleanup routine stopped as context was cancelled
if fetchedCacheValue, found := cache.Get(ast.StringTerm("foo").Value); !found {
t.Fatalf("Expected cache entry with value %v for foo, found %v", cacheValue, fetchedCacheValue)
}
}
func TestUpdateConfig(t *testing.T) {
t.Parallel()
config, err := ParseCachingConfig(nil)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
c := NewInterQueryCache(config)
actualC, ok := c.(*cache)
if !ok {
t.Fatal("Unexpected error converting InterQueryCache to cache struct")
}
if actualC.config != config {
t.Fatal("Cache config is different than expected")
}
actualC.UpdateConfig(nil)
if actualC.config != config {
t.Fatal("Cache config is different than expected after a nil update")
}
config2, err := ParseCachingConfig(nil)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
actualC.UpdateConfig(config2)
if actualC.config != config2 {
t.Fatal("Cache config is different than expected after update")
}
}
func TestDefaultConfigValues(t *testing.T) {
t.Parallel()
c := NewInterQueryCache(nil)
actualC, ok := c.(*cache)
if !ok {
t.Fatal("Unexpected error converting InterQueryCache to cache struct")
}
if actualC.maxSizeBytes() != defaultMaxSizeBytes {
t.Fatal("Expected maxSizeBytes() to return default when config is nil")
}
if actualC.forcedEvictionThresholdPercentage() != defaultForcedEvictionThresholdPercentage {
t.Fatal("Expected forcedEvictionThresholdPercentage() to return default when config is nil")
}
if actualC.staleEntryEvictionTimePeriodSeconds() != defaultStaleEntryEvictionPeriodSeconds {
t.Fatal("Expected staleEntryEvictionTimePeriodSeconds() to return default when config is nil")
}
}
// Verifies that the size of c.l is identical to the size of c.items
// Since the size of c.items is limited by c.usage, this helps us
// avoid a situation where c.l can grow indefinitely causing a memory leak
func verifyCacheList(t *testing.T, c InterQueryCache) {
actualC, ok := c.(*cache)
if !ok {
t.Fatal("Unexpected error converting InterQueryCache to cache struct")
}
if len(actualC.items) != actualC.l.Len() {
t.Fatal("actualC.l should contain equally many elements as actualC.items")
}
}
type testInterQueryCacheValue struct {
value ast.Value
size int
}
func newInterQueryCacheValue(value ast.Value, size int) *testInterQueryCacheValue {
return &testInterQueryCacheValue{value: value, size: size}
}
func (p testInterQueryCacheValue) SizeInBytes() int64 {
return int64(p.size)
}
func (p testInterQueryCacheValue) Clone() (InterQueryCacheValue, error) {
return &testInterQueryCacheValue{value: p.value, size: p.size}, nil
}
func TestConfigClone(t *testing.T) {
// test nil config
var nilConfig *Config
cloned := nilConfig.Clone()
if cloned != nil {
t.Fatal("expected nil clone for nil config")
}
// test config with all fields populated using reflection
original := test.PopulateAllFields[Config](t)
cloned = original.Clone()
if cloned == nil {
t.Fatal("clone returned nil")
}
if cloned == original {
t.Fatal("clone should be different instance")
}
if !reflect.DeepEqual(original, cloned) {
t.Errorf("clone differs from original")
}
// Test that modifying the clone doesn't affect the original
*cloned.InterQueryBuiltinCache.MaxSizeBytes = 999
if *original.InterQueryBuiltinCache.MaxSizeBytes == 999 {
t.Errorf("modifying clone affected original")
}
}