@@ -9,6 +9,7 @@ package sherdlock
99import (
1010 "context"
1111 "errors"
12+ "sync"
1213 "sync/atomic"
1314 "testing"
1415 "time"
@@ -980,3 +981,101 @@ func (m *mockStoreServiceManager) StoreServiceByTMSId(tmsID token.TMSID) (*token
980981
981982 return nil , errors .New ("not implemented" )
982983}
984+
985+ // TestCachedFetcher_UpdateDoesNotBlockReaders tests that the update() function
986+ // releases the lock during the potentially slow DB operation, allowing concurrent
987+ // readers to access the cache. This is the fix for issue #16.
988+ func TestCachedFetcher_UpdateDoesNotBlockReaders (t * testing.T ) {
989+ mockDB := new (mockTokenDB )
990+ // Use long freshness interval so cache won't be stale
991+ fetcher := newCachedFetcher (mockDB , 0 , 10 * time .Second , 100 )
992+
993+ // Pre-populate the cache so readers can hit it
994+ initialTokens := []* token2.UnspentTokenInWallet {
995+ {WalletID : "wallet1" , Type : "USD" , Quantity : "100" },
996+ }
997+ mockIterator := iterators .Slice (initialTokens )
998+ mockDB .On ("SpendableTokensIteratorBy" , mock .Anything , "" , token2 .Type ("" )).Return (mockIterator , nil ).Once ()
999+
1000+ ctx := t .Context ()
1001+ fetcher .update (ctx )
1002+
1003+ // Make cache stale so update() will be called
1004+ fetcher .lastFetched = time .Now ().Add (- 20 * time .Second )
1005+
1006+ // Use a channel to simulate a slow DB operation
1007+ slowDB := make (chan struct {})
1008+ tokensAfterSlowDB := []* token2.UnspentTokenInWallet {
1009+ {WalletID : "wallet1" , Type : "USD" , Quantity : "200" },
1010+ }
1011+ mockIterator2 := iterators .Slice (tokensAfterSlowDB )
1012+ mockDB .On ("SpendableTokensIteratorBy" , mock .Anything , "" , token2 .Type ("" )).Return (mockIterator2 , nil ).Run (func (args mock.Arguments ) {
1013+ <- slowDB // Wait before returning to simulate slow DB
1014+ }).Once ()
1015+
1016+ // Track whether reader succeeded while update() was blocked on DB
1017+ var readerSuccess atomic.Bool
1018+ var readerWg sync.WaitGroup
1019+
1020+ // Start update in background (it will block on DB call)
1021+ readerWg .Add (1 )
1022+ go func () {
1023+ defer readerWg .Done ()
1024+ fetcher .update (ctx )
1025+ }()
1026+
1027+ // Small delay to ensure update() has released lock and is waiting on DB
1028+ time .Sleep (10 * time .Millisecond )
1029+
1030+ // Reader should be able to acquire RLock while update() waits on DB
1031+ // This would deadlock before the fix (issue #16)
1032+ fetcher .mu .RLock ()
1033+ _ , ok := fetcher .cache .Get (tokenKey ("wallet1" , "USD" ))
1034+ fetcher .mu .RUnlock ()
1035+
1036+ if ok {
1037+ readerSuccess .Store (true )
1038+ }
1039+
1040+ // Signal DB to complete
1041+ close (slowDB )
1042+
1043+ // Wait for update to complete
1044+ readerWg .Wait ()
1045+
1046+ // Verify reader succeeded - the cache should still be accessible during update
1047+ assert .True (t , readerSuccess .Load (), "reader should be able to access cache while update() is blocked on DB" )
1048+ mockDB .AssertExpectations (t )
1049+ }
1050+
1051+ // TestCachedFetcher_UpdateReacquiresLockAfterDB tests that after the DB operation
1052+ // completes, update() correctly re-acquires the lock and performs the cache update.
1053+ func TestCachedFetcher_UpdateReacquiresLockAfterDB (t * testing.T ) {
1054+ mockDB := new (mockTokenDB )
1055+ fetcher := newCachedFetcher (mockDB , 0 , 1 * time .Second , 100 )
1056+
1057+ // Pre-populate to make cache appear stale
1058+ fetcher .lastFetched = time .Now ().Add (- 20 * time .Second )
1059+
1060+ tokens := []* token2.UnspentTokenInWallet {
1061+ {WalletID : "wallet1" , Type : "USD" , Quantity : "300" },
1062+ }
1063+ mockIterator := iterators .Slice (tokens )
1064+ mockDB .On ("SpendableTokensIteratorBy" , mock .Anything , "" , token2 .Type ("" )).Return (mockIterator , nil ).Once ()
1065+
1066+ ctx := t .Context ()
1067+ fetcher .update (ctx )
1068+
1069+ // After update completes, cache should be refreshed (not stale)
1070+ assert .False (t , fetcher .isCacheStale ())
1071+ assert .Equal (t , uint32 (0 ), atomic .LoadUint32 (& fetcher .queriesResponded ))
1072+
1073+ // Token should be in cache
1074+ fetcher .mu .RLock ()
1075+ _ , ok := fetcher .cache .Get (tokenKey ("wallet1" , "USD" ))
1076+ fetcher .mu .RUnlock ()
1077+ assert .True (t , ok , "token should be in cache after update" )
1078+
1079+ mockDB .AssertExpectations (t )
1080+ }
1081+
0 commit comments