github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/cache/local_test.go (about)

     1  package cache
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"sync"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/rs/zerolog"
    11  	"github.com/stretchr/testify/require"
    12  )
    13  
    14  func TestNewLocal(t *testing.T) {
    15  	logger := zerolog.New(io.Discard)
    16  
    17  	t.Run("valid configuration", func(t *testing.T) {
    18  		cache, err := NewLocal(LocalConfig{
    19  			InitialCapacity: 100,
    20  			MaxCapacity:     1000,
    21  			DefaultTTL:      5 * time.Minute,
    22  			Logger:          logger,
    23  		})
    24  		require.NoError(t, err)
    25  
    26  		require.NotNil(t, cache)
    27  		defer cache.Close()
    28  
    29  		require.Implements(t, (*Cache)(nil), cache)
    30  	})
    31  
    32  	t.Run("minimal configuration", func(t *testing.T) {
    33  		cache, err := NewLocal(LocalConfig{
    34  			MaxCapacity: 100,
    35  			Logger:      logger,
    36  		})
    37  		require.NoError(t, err)
    38  
    39  		require.NotNil(t, cache)
    40  		defer cache.Close()
    41  
    42  		require.Equal(t, 100, cache.Capacity())
    43  	})
    44  
    45  	t.Run("with default TTL", func(t *testing.T) {
    46  		cache, err := NewLocal(LocalConfig{
    47  			MaxCapacity: 100,
    48  			DefaultTTL:  10 * time.Second,
    49  			Logger:      logger,
    50  		})
    51  		require.NoError(t, err)
    52  
    53  		require.NotNil(t, cache)
    54  		defer cache.Close()
    55  	})
    56  }
    57  
    58  func TestNewLocalErrors(t *testing.T) {
    59  	logger := zerolog.New(io.Discard)
    60  
    61  	t.Run("zero capacity", func(t *testing.T) {
    62  		cache, err := NewLocal(LocalConfig{
    63  			MaxCapacity: 0,
    64  			Logger:      logger,
    65  		})
    66  		require.Error(t, err)
    67  		require.Nil(t, cache)
    68  		require.Contains(t, err.Error(), "max capacity must be positive")
    69  	})
    70  
    71  	t.Run("negative capacity", func(t *testing.T) {
    72  		cache, err := NewLocal(LocalConfig{
    73  			MaxCapacity: -1,
    74  			Logger:      logger,
    75  		})
    76  		require.Error(t, err)
    77  		require.Nil(t, cache)
    78  		require.Contains(t, err.Error(), "max capacity must be positive")
    79  	})
    80  }
    81  
    82  func TestLocalCacheSetGet(t *testing.T) {
    83  	logger := zerolog.New(io.Discard)
    84  	cache, err := NewLocal(LocalConfig{
    85  		MaxCapacity: 1000,
    86  		DefaultTTL:  1 * time.Minute,
    87  		Logger:      logger,
    88  	})
    89  	require.NoError(t, err)
    90  
    91  	defer cache.Close()
    92  
    93  	ctx := t.Context()
    94  
    95  	t.Run("set and get string", func(t *testing.T) {
    96  		key := "test:string"
    97  		value := "hello world"
    98  
    99  		err := cache.Set(ctx, key, value, 5*time.Minute)
   100  		require.NoError(t, err)
   101  
   102  		var result string
   103  
   104  		err = cache.Get(ctx, key, &result)
   105  		require.NoError(t, err)
   106  		require.Equal(t, value, result)
   107  	})
   108  
   109  	t.Run("set and get int", func(t *testing.T) {
   110  		key := "test:int"
   111  		value := 42
   112  
   113  		err := cache.Set(ctx, key, value, 5*time.Minute)
   114  		require.NoError(t, err)
   115  
   116  		var result int
   117  
   118  		err = cache.Get(ctx, key, &result)
   119  		require.NoError(t, err)
   120  		require.Equal(t, value, result)
   121  	})
   122  
   123  	t.Run("set and get struct", func(t *testing.T) {
   124  		key := "test:struct"
   125  		value := TestStruct{
   126  			ID:   123,
   127  			Name: "test",
   128  			Tags: []string{"a", "b", "c"},
   129  		}
   130  
   131  		err := cache.Set(ctx, key, value, 5*time.Minute)
   132  		require.NoError(t, err)
   133  
   134  		var result TestStruct
   135  
   136  		err = cache.Get(ctx, key, &result)
   137  		require.NoError(t, err)
   138  		require.Equal(t, value, result)
   139  	})
   140  
   141  	t.Run("set and get slice", func(t *testing.T) {
   142  		key := "test:slice"
   143  		value := []string{"x", "y", "z"}
   144  
   145  		err := cache.Set(ctx, key, value, 5*time.Minute)
   146  		require.NoError(t, err)
   147  
   148  		var result []string
   149  
   150  		err = cache.Get(ctx, key, &result)
   151  		require.NoError(t, err)
   152  		require.Equal(t, value, result)
   153  	})
   154  
   155  	t.Run("set and get map", func(t *testing.T) {
   156  		key := "test:map"
   157  		value := map[string]int{"a": 1, "b": 2, "c": 3}
   158  
   159  		err := cache.Set(ctx, key, value, 5*time.Minute)
   160  		require.NoError(t, err)
   161  
   162  		var result map[string]int
   163  
   164  		err = cache.Get(ctx, key, &result)
   165  		require.NoError(t, err)
   166  		require.Equal(t, value, result)
   167  	})
   168  
   169  	t.Run("get non-existent key", func(t *testing.T) {
   170  		var result string
   171  
   172  		err := cache.Get(ctx, "nonexistent:key", &result)
   173  		require.ErrorIs(t, err, ErrCacheMiss)
   174  		require.Empty(t, result)
   175  	})
   176  
   177  	t.Run("overwrite existing key", func(t *testing.T) {
   178  		key := "test:overwrite"
   179  
   180  		err := cache.Set(ctx, key, "first value", 5*time.Minute)
   181  		require.NoError(t, err)
   182  
   183  		err = cache.Set(ctx, key, "second value", 5*time.Minute)
   184  		require.NoError(t, err)
   185  
   186  		var result string
   187  
   188  		err = cache.Get(ctx, key, &result)
   189  		require.NoError(t, err)
   190  		require.Equal(t, "second value", result)
   191  	})
   192  }
   193  
   194  func TestLocalCacheExpiration(t *testing.T) {
   195  	logger := zerolog.New(io.Discard)
   196  	cache, err := NewLocal(LocalConfig{
   197  		MaxCapacity: 1000,
   198  		DefaultTTL:  1 * time.Second,
   199  		Logger:      logger,
   200  	})
   201  	require.NoError(t, err)
   202  
   203  	defer cache.Close()
   204  
   205  	ctx := t.Context()
   206  
   207  	t.Run("item expires after TTL", func(t *testing.T) {
   208  		key := "test:expire"
   209  		value := "will expire"
   210  
   211  		// Set with short TTL
   212  		err := cache.Set(ctx, key, value, 100*time.Millisecond)
   213  		require.NoError(t, err)
   214  
   215  		// Should be available immediately
   216  		var result string
   217  
   218  		err = cache.Get(ctx, key, &result)
   219  		require.NoError(t, err)
   220  		require.Equal(t, value, result)
   221  
   222  		// Wait for expiration
   223  		time.Sleep(200 * time.Millisecond)
   224  
   225  		// Trigger cleanup to remove expired items
   226  		cache.cache.CleanUp()
   227  
   228  		// Should be expired now
   229  		err = cache.Get(ctx, key, &result)
   230  		require.ErrorIs(t, err, ErrCacheMiss)
   231  	})
   232  
   233  	t.Run("use default TTL when expiration is zero", func(t *testing.T) {
   234  		key := "test:default:ttl"
   235  		value := "default ttl"
   236  
   237  		// Set with zero expiration (should use default TTL)
   238  		err := cache.Set(ctx, key, value, 0)
   239  		require.NoError(t, err)
   240  
   241  		// Should be available immediately
   242  		var result string
   243  
   244  		err = cache.Get(ctx, key, &result)
   245  		require.NoError(t, err)
   246  		require.Equal(t, value, result)
   247  
   248  		// Wait for default TTL to expire
   249  		time.Sleep(1500 * time.Millisecond)
   250  
   251  		// Trigger cleanup to remove expired items
   252  		cache.cache.CleanUp()
   253  
   254  		// Should be expired now
   255  		err = cache.Get(ctx, key, &result)
   256  		require.ErrorIs(t, err, ErrCacheMiss)
   257  	})
   258  }
   259  
   260  func TestLocalCacheDelete(t *testing.T) {
   261  	logger := zerolog.New(io.Discard)
   262  	cache, err := NewLocal(LocalConfig{
   263  		MaxCapacity: 1000,
   264  		Logger:      logger,
   265  	})
   266  	require.NoError(t, err)
   267  
   268  	defer cache.Close()
   269  
   270  	ctx := t.Context()
   271  
   272  	t.Run("delete existing key", func(t *testing.T) {
   273  		key := "test:delete"
   274  		value := "to be deleted"
   275  
   276  		err := cache.Set(ctx, key, value, 5*time.Minute)
   277  		require.NoError(t, err)
   278  
   279  		// Verify it's there
   280  		var result string
   281  
   282  		err = cache.Get(ctx, key, &result)
   283  		require.NoError(t, err)
   284  
   285  		// Delete it
   286  		err = cache.Delete(ctx, key)
   287  		require.NoError(t, err)
   288  
   289  		// Verify it's gone
   290  		err = cache.Get(ctx, key, &result)
   291  		require.ErrorIs(t, err, ErrCacheMiss)
   292  	})
   293  
   294  	t.Run("delete non-existent key", func(t *testing.T) {
   295  		err := cache.Delete(ctx, "nonexistent:delete")
   296  		require.NoError(t, err) // Should be idempotent
   297  	})
   298  }
   299  
   300  func TestLocalCacheFlush(t *testing.T) {
   301  	logger := zerolog.New(io.Discard)
   302  	cache, err := NewLocal(LocalConfig{
   303  		MaxCapacity: 1000,
   304  		Logger:      logger,
   305  	})
   306  	require.NoError(t, err)
   307  
   308  	defer cache.Close()
   309  
   310  	ctx := t.Context()
   311  
   312  	// Add multiple items
   313  	for i := range 10 {
   314  		key := fmt.Sprintf("test:flush:%d", i)
   315  		err := cache.Set(ctx, key, i, 5*time.Minute)
   316  		require.NoError(t, err)
   317  	}
   318  
   319  	// Verify items are there
   320  	require.Greater(t, cache.Size(), 0)
   321  
   322  	// Flush
   323  	err = cache.Flush(ctx)
   324  	require.NoError(t, err)
   325  
   326  	// Verify all items are gone
   327  	require.Equal(t, 0, cache.Size())
   328  
   329  	// Verify individual items are gone
   330  	var result int
   331  
   332  	err = cache.Get(ctx, "test:flush:0", &result)
   333  	require.ErrorIs(t, err, ErrCacheMiss)
   334  }
   335  
   336  func TestLocalCacheCapacity(t *testing.T) {
   337  	logger := zerolog.New(io.Discard)
   338  
   339  	// Small capacity for testing
   340  	cache, err := NewLocal(LocalConfig{
   341  		MaxCapacity: 10,
   342  		Logger:      logger,
   343  	})
   344  	require.NoError(t, err)
   345  
   346  	defer cache.Close()
   347  
   348  	ctx := t.Context()
   349  
   350  	// Add more items than capacity
   351  	for i := range 20 {
   352  		key := fmt.Sprintf("test:capacity:%d", i)
   353  		err := cache.Set(ctx, key, i, 5*time.Minute)
   354  		require.NoError(t, err)
   355  	}
   356  
   357  	// Trigger cleanup to enforce capacity limits
   358  	cache.cache.CleanUp()
   359  
   360  	// Size should not exceed capacity (allowing some margin for async operations)
   361  	// Otter may temporarily exceed capacity, so we check it's reasonably close
   362  	require.LessOrEqual(t, cache.Size(), cache.Capacity()+5)
   363  	require.Equal(t, 10, cache.Capacity())
   364  }
   365  
   366  func TestLocalCacheConcurrency(t *testing.T) {
   367  	logger := zerolog.New(io.Discard)
   368  	cache, err := NewLocal(LocalConfig{
   369  		MaxCapacity: 10000,
   370  		Logger:      logger,
   371  	})
   372  	require.NoError(t, err)
   373  
   374  	defer cache.Close()
   375  
   376  	ctx := t.Context()
   377  
   378  	t.Run("concurrent writes", func(t *testing.T) {
   379  		var wg sync.WaitGroup
   380  
   381  		numGoroutines := 100
   382  		itemsPerGoroutine := 10
   383  
   384  		wg.Add(numGoroutines)
   385  
   386  		for i := range numGoroutines {
   387  			go func(id int) {
   388  				defer wg.Done()
   389  
   390  				for j := range itemsPerGoroutine {
   391  					key := fmt.Sprintf("concurrent:write:%d:%d", id, j)
   392  					err := cache.Set(ctx, key, id*itemsPerGoroutine+j, 5*time.Minute)
   393  					require.NoError(t, err)
   394  				}
   395  			}(i)
   396  		}
   397  
   398  		wg.Wait()
   399  	})
   400  
   401  	t.Run("concurrent reads and writes", func(t *testing.T) {
   402  		// Pre-populate cache
   403  		for i := range 100 {
   404  			key := fmt.Sprintf("concurrent:rw:%d", i)
   405  			err := cache.Set(ctx, key, i, 5*time.Minute)
   406  			require.NoError(t, err)
   407  		}
   408  
   409  		var wg sync.WaitGroup
   410  
   411  		numReaders := 50
   412  		numWriters := 50
   413  
   414  		// Start readers
   415  		wg.Add(numReaders)
   416  
   417  		for i := range numReaders {
   418  			go func(id int) {
   419  				defer wg.Done()
   420  
   421  				for j := range 100 {
   422  					key := fmt.Sprintf("concurrent:rw:%d", j)
   423  
   424  					var result int
   425  
   426  					_ = cache.Get(ctx, key, &result)
   427  				}
   428  			}(i)
   429  		}
   430  
   431  		// Start writers
   432  		wg.Add(numWriters)
   433  
   434  		for i := range numWriters {
   435  			go func(id int) {
   436  				defer wg.Done()
   437  
   438  				for j := range 100 {
   439  					key := fmt.Sprintf("concurrent:rw:%d", j)
   440  					_ = cache.Set(ctx, key, id*100+j, 5*time.Minute)
   441  				}
   442  			}(i)
   443  		}
   444  
   445  		wg.Wait()
   446  	})
   447  }
   448  
   449  func TestLocalCacheEncodeErrors(t *testing.T) {
   450  	logger := zerolog.New(io.Discard)
   451  	cache, err := NewLocal(LocalConfig{
   452  		MaxCapacity: 1000,
   453  		Logger:      logger,
   454  	})
   455  	require.NoError(t, err)
   456  
   457  	defer cache.Close()
   458  
   459  	ctx := t.Context()
   460  
   461  	t.Run("cannot encode channel", func(t *testing.T) {
   462  		err := cache.Set(ctx, "test:channel", make(chan int), 5*time.Minute)
   463  		require.Error(t, err)
   464  		require.Contains(t, err.Error(), "failed to encode value")
   465  	})
   466  
   467  	t.Run("cannot encode function", func(t *testing.T) {
   468  		err := cache.Set(ctx, "test:func", func() {}, 5*time.Minute)
   469  		require.Error(t, err)
   470  		require.Contains(t, err.Error(), "failed to encode value")
   471  	})
   472  }
   473  
   474  func TestLocalCacheDecodeErrors(t *testing.T) {
   475  	logger := zerolog.New(io.Discard)
   476  	cache, err := NewLocal(LocalConfig{
   477  		MaxCapacity: 1000,
   478  		Logger:      logger,
   479  	})
   480  	require.NoError(t, err)
   481  
   482  	defer cache.Close()
   483  
   484  	ctx := t.Context()
   485  
   486  	// Store a string
   487  	err = cache.Set(ctx, "test:mismatch", "hello", 5*time.Minute)
   488  	require.NoError(t, err)
   489  
   490  	// Try to retrieve as int
   491  	var result int
   492  
   493  	err = cache.Get(ctx, "test:mismatch", &result)
   494  	require.Error(t, err)
   495  	require.Contains(t, err.Error(), "failed to decode value")
   496  }
   497  
   498  func TestLocalCacheClose(t *testing.T) {
   499  	logger := zerolog.New(io.Discard)
   500  
   501  	t.Run("close succeeds", func(t *testing.T) {
   502  		cache, err := NewLocal(LocalConfig{
   503  			MaxCapacity: 1000,
   504  			Logger:      logger,
   505  		})
   506  		require.NoError(t, err)
   507  
   508  		err = cache.Close()
   509  		require.NoError(t, err)
   510  	})
   511  
   512  	t.Run("operations after close", func(t *testing.T) {
   513  		cache, err := NewLocal(LocalConfig{
   514  			MaxCapacity: 1000,
   515  			Logger:      logger,
   516  		})
   517  		require.NoError(t, err)
   518  
   519  		ctx := t.Context()
   520  
   521  		// Add some data
   522  		err = cache.Set(ctx, "test:key", "value", 5*time.Minute)
   523  		require.NoError(t, err)
   524  
   525  		// Close cache
   526  		err = cache.Close()
   527  		require.NoError(t, err)
   528  
   529  		// Operations after close may panic or fail.
   530  		// This is expected behavior - cache should not be used after Close().
   531  		//
   532  		// With the local implementation, it might not be worth it wrap all the operations with
   533  		// "if isClosed", and because of the way that Otter works, calling Close doesn't really
   534  		// mean the cache has been cleared.
   535  
   536  		if false { // This won't work for now.
   537  			var result string
   538  
   539  			err = cache.Get(ctx, "test:key", &result)
   540  			require.Error(t, err, ErrCacheMiss)
   541  		}
   542  	})
   543  }
   544  
   545  // Benchmark tests
   546  
   547  func BenchmarkLocalCacheSet(b *testing.B) {
   548  	logger := zerolog.New(io.Discard)
   549  
   550  	cache, _ := NewLocal(LocalConfig{
   551  		MaxCapacity: 100000,
   552  		Logger:      logger,
   553  	})
   554  	defer cache.Close()
   555  
   556  	ctx := b.Context()
   557  	value := "benchmark value"
   558  
   559  	b.ResetTimer()
   560  
   561  	for i := 0; b.Loop(); i++ {
   562  		key := fmt.Sprintf("bench:set:%d", i%10000)
   563  		_ = cache.Set(ctx, key, value, 5*time.Minute)
   564  	}
   565  }
   566  
   567  func BenchmarkLocalCacheGet(b *testing.B) {
   568  	logger := zerolog.New(io.Discard)
   569  
   570  	cache, _ := NewLocal(LocalConfig{
   571  		MaxCapacity: 100000,
   572  		Logger:      logger,
   573  	})
   574  	defer cache.Close()
   575  
   576  	ctx := b.Context()
   577  
   578  	// Pre-populate
   579  	for i := range 10000 {
   580  		key := fmt.Sprintf("bench:get:%d", i)
   581  		_ = cache.Set(ctx, key, i, 5*time.Minute)
   582  	}
   583  
   584  	b.ResetTimer()
   585  
   586  	var result int
   587  
   588  	for i := 0; b.Loop(); i++ {
   589  		key := fmt.Sprintf("bench:get:%d", i%10000)
   590  		_ = cache.Get(ctx, key, &result)
   591  	}
   592  }
   593  
   594  func BenchmarkLocalCacheConcurrent(b *testing.B) {
   595  	logger := zerolog.New(io.Discard)
   596  
   597  	cache, _ := NewLocal(LocalConfig{
   598  		MaxCapacity: 100000,
   599  		Logger:      logger,
   600  	})
   601  	defer cache.Close()
   602  
   603  	ctx := b.Context()
   604  
   605  	// Pre-populate
   606  	for i := range 1000 {
   607  		key := fmt.Sprintf("bench:concurrent:%d", i)
   608  		_ = cache.Set(ctx, key, i, 5*time.Minute)
   609  	}
   610  
   611  	b.ResetTimer()
   612  
   613  	b.RunParallel(func(pb *testing.PB) {
   614  		i := 0
   615  
   616  		var result int
   617  
   618  		for pb.Next() {
   619  			key := fmt.Sprintf("bench:concurrent:%d", i%1000)
   620  			if i%2 == 0 {
   621  				_ = cache.Get(ctx, key, &result)
   622  			} else {
   623  				_ = cache.Set(ctx, key, i, 5*time.Minute)
   624  			}
   625  
   626  			i++
   627  		}
   628  	})
   629  }