Skip to content

Commit 086ee34

Browse files
committed
increase test coverage to 99%
1 parent 1aa44e3 commit 086ee34

4 files changed

Lines changed: 2301 additions & 47 deletions

File tree

memory_test.go

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,3 +660,324 @@ func TestCache_CapacityEfficiency_StringKeys(t *testing.T) {
660660
})
661661
}
662662
}
663+
664+
func TestCache_SetIfAbsent_Basic(t *testing.T) {
665+
cache := New[string, int]()
666+
defer cache.Close()
667+
668+
// SetIfAbsent on missing key should insert
669+
val, existed := cache.SetIfAbsent("key1", 42)
670+
if existed {
671+
t.Error("key1 should not have existed")
672+
}
673+
if val != 42 {
674+
t.Errorf("SetIfAbsent returned %d; want 42", val)
675+
}
676+
677+
// Verify it was stored
678+
got, found := cache.Get("key1")
679+
if !found || got != 42 {
680+
t.Errorf("Get after SetIfAbsent: got %d, found %v; want 42, true", got, found)
681+
}
682+
683+
// SetIfAbsent on existing key should return existing value
684+
val, existed = cache.SetIfAbsent("key1", 100)
685+
if !existed {
686+
t.Error("key1 should have existed")
687+
}
688+
if val != 42 {
689+
t.Errorf("SetIfAbsent returned %d; want 42 (original value)", val)
690+
}
691+
692+
// Verify value unchanged
693+
got, found = cache.Get("key1")
694+
if !found || got != 42 {
695+
t.Errorf("Get after second SetIfAbsent: got %d, found %v; want 42, true", got, found)
696+
}
697+
}
698+
699+
func TestCache_SetIfAbsent_WithTTL(t *testing.T) {
700+
cache := New[string, int](TTL(time.Hour))
701+
defer cache.Close()
702+
703+
// SetIfAbsent with explicit TTL
704+
val, existed := cache.SetIfAbsent("key1", 42, 50*time.Millisecond)
705+
if existed {
706+
t.Error("key1 should not have existed")
707+
}
708+
if val != 42 {
709+
t.Errorf("SetIfAbsent returned %d; want 42", val)
710+
}
711+
712+
// Value should be available immediately
713+
if _, found := cache.Get("key1"); !found {
714+
t.Error("key1 should be found immediately after SetIfAbsent")
715+
}
716+
717+
// Wait for TTL to expire
718+
time.Sleep(100 * time.Millisecond)
719+
720+
// Value should be expired
721+
if _, found := cache.Get("key1"); found {
722+
t.Error("key1 should be expired")
723+
}
724+
}
725+
726+
func TestCache_SetIfAbsent_NoTTL(t *testing.T) {
727+
cache := New[string, int]() // No default TTL
728+
defer cache.Close()
729+
730+
// SetIfAbsent without TTL on cache with no default
731+
val, existed := cache.SetIfAbsent("key1", 42)
732+
if existed {
733+
t.Error("key1 should not have existed")
734+
}
735+
if val != 42 {
736+
t.Errorf("SetIfAbsent returned %d; want 42", val)
737+
}
738+
739+
// Value should persist indefinitely (no expiry)
740+
time.Sleep(50 * time.Millisecond)
741+
if _, found := cache.Get("key1"); !found {
742+
t.Error("key1 should still exist (no TTL)")
743+
}
744+
}
745+
746+
func TestCache_SetIfAbsent_Concurrent(t *testing.T) {
747+
cache := New[int, int](Size(1000))
748+
defer cache.Close()
749+
750+
var wg sync.WaitGroup
751+
existedCount := atomic.Int32{}
752+
notExistedCount := atomic.Int32{}
753+
754+
// Many goroutines try to SetIfAbsent the same key
755+
for range 100 {
756+
wg.Add(1)
757+
go func() {
758+
defer wg.Done()
759+
_, existed := cache.SetIfAbsent(42, 100)
760+
if existed {
761+
existedCount.Add(1)
762+
} else {
763+
notExistedCount.Add(1)
764+
}
765+
}()
766+
}
767+
768+
wg.Wait()
769+
770+
// At least some should have seen it as existing (not all created it)
771+
// SetIfAbsent is not fully atomic (get then set), so multiple may "win"
772+
if existedCount.Load() < 50 {
773+
t.Errorf("existedCount = %d; want >= 50 (most should see existing)", existedCount.Load())
774+
}
775+
776+
// Final value should be 100
777+
if val, found := cache.Get(42); !found || val != 100 {
778+
t.Errorf("Get(42) = %d, %v; want 100, true", val, found)
779+
}
780+
781+
t.Logf("existedCount = %d, notExistedCount = %d", existedCount.Load(), notExistedCount.Load())
782+
}
783+
784+
func TestCache_Close_NoOp(t *testing.T) {
785+
cache := New[string, int]()
786+
787+
// Close should be a no-op for in-memory cache
788+
cache.Close()
789+
790+
// Multiple closes should be safe
791+
cache.Close()
792+
cache.Close()
793+
}
794+
795+
func TestCache_GetSet_CacheHitDuringSingleflight(t *testing.T) {
796+
cache := New[string, int](Size(1000))
797+
defer cache.Close()
798+
799+
var wg sync.WaitGroup
800+
loaderCalls := atomic.Int32{}
801+
802+
// Start first loader that's slow
803+
wg.Add(1)
804+
go func() {
805+
defer wg.Done()
806+
_, _ = cache.GetSet("key1", func() (int, error) {
807+
loaderCalls.Add(1)
808+
// While loader is running, another goroutine populates cache
809+
time.Sleep(100 * time.Millisecond)
810+
return 42, nil
811+
})
812+
}()
813+
814+
// Let first goroutine start and enter singleflight
815+
time.Sleep(10 * time.Millisecond)
816+
817+
// While first is waiting, directly set the value in cache
818+
cache.Set("key1", 99)
819+
820+
// Start second loader that should wait for first
821+
wg.Add(1)
822+
go func() {
823+
defer wg.Done()
824+
val, _ := cache.GetSet("key1", func() (int, error) {
825+
loaderCalls.Add(1)
826+
return 77, nil
827+
})
828+
// Second should get either 99 (from cache) or 42 (from first loader)
829+
if val != 99 && val != 42 {
830+
t.Errorf("unexpected value: %d", val)
831+
}
832+
}()
833+
834+
wg.Wait()
835+
836+
t.Logf("loader calls: %d", loaderCalls.Load())
837+
}
838+
839+
func TestCache_GetSet_RaceCondition(t *testing.T) {
840+
// Test the path where cache is populated between first check and singleflight
841+
cache := New[string, int](Size(1000))
842+
defer cache.Close()
843+
844+
var wg sync.WaitGroup
845+
846+
// Run many concurrent GetSets with a mix of slow and fast loaders
847+
for i := range 20 {
848+
wg.Add(1)
849+
go func(idx int) {
850+
defer wg.Done()
851+
key := fmt.Sprintf("key%d", idx%5) // Only 5 unique keys
852+
853+
val, err := cache.GetSet(key, func() (int, error) {
854+
if idx%3 == 0 {
855+
time.Sleep(10 * time.Millisecond)
856+
}
857+
return idx * 10, nil
858+
})
859+
860+
if err != nil {
861+
t.Errorf("GetSet error: %v", err)
862+
}
863+
if val < 0 {
864+
t.Errorf("unexpected value: %d", val)
865+
}
866+
}(i)
867+
}
868+
869+
wg.Wait()
870+
}
871+
872+
// TestCache_GetSet_MemoryHitAfterSingleflightAcquire tests the path where
873+
// the cache is populated between winning singleflight and checking cache again.
874+
func TestCache_GetSet_MemoryHitAfterSingleflightAcquire(t *testing.T) {
875+
// This is tricky to test because the window is very small.
876+
// We use a contrived scenario with concurrent access.
877+
cache := New[string, int](Size(100))
878+
defer cache.Close()
879+
880+
// Key that will be set by another goroutine
881+
const key = "contested"
882+
883+
var started sync.WaitGroup
884+
started.Add(1)
885+
886+
var done sync.WaitGroup
887+
done.Add(2)
888+
889+
// First goroutine: slow loader
890+
go func() {
891+
defer done.Done()
892+
started.Done() // Signal that we've started
893+
894+
_, _ = cache.GetSet(key, func() (int, error) {
895+
// Wait long enough for the second Set to happen
896+
time.Sleep(50 * time.Millisecond)
897+
return 1, nil
898+
})
899+
}()
900+
901+
// Wait for first goroutine to start
902+
started.Wait()
903+
time.Sleep(5 * time.Millisecond)
904+
905+
// Second goroutine: direct Set while first is in loader
906+
go func() {
907+
defer done.Done()
908+
cache.Set(key, 99)
909+
}()
910+
911+
done.Wait()
912+
913+
// Value should be either 99 (from Set) or 1 (from loader)
914+
if val, ok := cache.Get(key); !ok {
915+
t.Error("key should exist")
916+
} else if val != 99 && val != 1 {
917+
t.Errorf("unexpected value: %d", val)
918+
}
919+
}
920+
921+
// TestCache_GetSet_WithDefaultTTL tests GetSet using the default TTL.
922+
func TestCache_GetSet_WithDefaultTTL(t *testing.T) {
923+
cache := New[string, int](TTL(time.Hour))
924+
defer cache.Close()
925+
926+
val, err := cache.GetSet("key1", func() (int, error) {
927+
return 42, nil
928+
})
929+
930+
if err != nil {
931+
t.Fatalf("GetSet error: %v", err)
932+
}
933+
if val != 42 {
934+
t.Errorf("GetSet value = %d; want 42", val)
935+
}
936+
}
937+
938+
// TestCache_GetSet_DoubleCheckPath attempts to hit the double-check cache hit path.
939+
// This path is triggered when:
940+
// 1. First check misses (no cache hit)
941+
// 2. We win the singleflight (not loaded)
942+
// 3. Another call populated the cache before our double-check
943+
// 4. Double-check finds the value
944+
func TestCache_GetSet_DoubleCheckPath(t *testing.T) {
945+
var hitCount int
946+
for iteration := range 1000 {
947+
cache := New[string, int](Size(100))
948+
949+
key := fmt.Sprintf("key%d", iteration)
950+
var loaderCalled atomic.Bool
951+
952+
var wg sync.WaitGroup
953+
wg.Add(2)
954+
955+
// Goroutine 1: Will try to win singleflight
956+
go func() {
957+
defer wg.Done()
958+
_, _ = cache.GetSet(key, func() (int, error) {
959+
loaderCalled.Store(true)
960+
return 1, nil
961+
})
962+
}()
963+
964+
// Goroutine 2: Directly sets value, racing with goroutine 1
965+
go func() {
966+
defer wg.Done()
967+
cache.Set(key, 99)
968+
}()
969+
970+
wg.Wait()
971+
cache.Close()
972+
973+
// If loader wasn't called, we hit the double-check path
974+
if !loaderCalled.Load() {
975+
hitCount++
976+
}
977+
}
978+
if hitCount > 0 {
979+
t.Logf("Hit double-check path %d times out of 1000", hitCount)
980+
} else {
981+
t.Log("Could not reliably hit double-check path (race dependent)")
982+
}
983+
}

0 commit comments

Comments
 (0)