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 }