@@ -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