go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/data/caching/lazyslot/lazyslot.go (about) 1 // Copyright 2015 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package lazyslot implements a caching scheme for globally shared objects that 16 // take significant time to refresh. 17 // 18 // The defining property of the implementation is that only one goroutine will 19 // block when refreshing such object, while all others will use a slightly stale 20 // cached copy. 21 package lazyslot 22 23 import ( 24 "context" 25 "sync" 26 "time" 27 28 "go.chromium.org/luci/common/clock" 29 "go.chromium.org/luci/common/logging" 30 ) 31 32 // ExpiresImmediately can be returned by the fetcher callback to indicate that 33 // the item must be refreshed on the next access. 34 // 35 // This is sometimes useful in tests with "frozen" time to disable caching. 36 const ExpiresImmediately time.Duration = -1 37 38 // Fetcher knows how to load a new value or refresh the existing one. 39 // 40 // It receives the previously known value when refreshing it. 41 // 42 // If the returned expiration duration is zero, the returned value never 43 // expires. If the returned expiration duration is equal to ExpiresImmediately, 44 // then the very next Get(...) will trigger another refresh (this is sometimes 45 // useful in tests with "frozen" time to disable caching). 46 type Fetcher func(prev any) (updated any, exp time.Duration, err error) 47 48 // Slot holds a cached value and refreshes it when it expires. 49 // 50 // Only one goroutine will be busy refreshing, all others will see a slightly 51 // stale copy of the value during the refresh. 52 type Slot struct { 53 RetryDelay time.Duration // how long to wait before fetching after a failure, 5 sec by default 54 55 lock sync.RWMutex // protects the guts below 56 initialized bool // true if fetched the initial value already 57 current any // currently known value (may be nil) 58 exp time.Time // when the currently known value expires or time.Time{} if never 59 fetching bool // true if some goroutine is fetching the value now 60 } 61 62 // Get returns stored value if it is still fresh or refetches it if it's stale. 63 // 64 // It may return slightly stale copy if some other goroutine is fetching a new 65 // copy now. If there's no cached copy at all, blocks until it is retrieved. 66 // 67 // Returns an error only when there's no cached copy yet and Fetcher returns 68 // an error. 69 // 70 // If there's an expired cached copy, and Fetcher returns an error when trying 71 // to refresh it, logs the error and returns the existing cached copy (which is 72 // stale at this point). We assume callers prefer stale copy over a hard error. 73 // 74 // On refetch errors bumps expiration time of the cached copy to RetryDelay 75 // seconds from now, effectively scheduling a retry at some later time. 76 // RetryDelay is 5 sec by default. 77 // 78 // The passed context is used for logging and for getting time. 79 func (s *Slot) Get(ctx context.Context, fetcher Fetcher) (value any, err error) { 80 now := clock.Now(ctx) 81 82 // Fast path. Checks a cached value exists and it is still fresh or some 83 // goroutine is already updating it (in that case we return a stale copy). 84 ok := false 85 s.lock.RLock() 86 if s.initialized && (s.fetching || isFresh(s.exp, now)) { 87 value = s.current 88 ok = true 89 } 90 s.lock.RUnlock() 91 if ok { 92 return 93 } 94 95 // Slow path. Attempt to start the fetch if no one beat us to it. 96 shouldFetch, value, err := s.initiateFetch(ctx, fetcher, now) 97 if !shouldFetch { 98 // Either someone did the fetch already, or the initial fetch failed. In 99 // either case 'value' and 'err' are already set, so just return them. 100 return 101 } 102 103 // 'value' here is currently known value that we are going to refresh. Need 104 // to clear the variable to make sure 'defer' below sees nil on panic. 105 prevValue := value 106 value = nil 107 108 // The current goroutine won the contest and now is responsible for refetching 109 // the value. Do it, but be cautious to fix the state in case of a panic. 110 var completed bool 111 var exp time.Duration 112 defer func() { s.finishFetch(completed, value, setExpiry(ctx, exp)) }() 113 114 value, exp, err = fetcher(prevValue) 115 completed = true // we didn't panic! 116 117 // Log the error and return the previous value, bumping its expiration time by 118 // retryDelay to trigger a retry at some later time. 119 if err != nil { 120 logging.WithError(err).Errorf(ctx, "lazyslot: failed to update instance of %T", prevValue) 121 value = prevValue 122 exp = s.retryDelay() 123 err = nil 124 } 125 126 return 127 } 128 129 // initiateFetch modifies state of Slot to indicate that the current goroutine 130 // is going to do the fetch if no one is fetching it now. 131 // 132 // Returns: 133 // - (true, known value, nil) if the current goroutine should refetch. 134 // - (false, known value, nil) if the fetch is no longer necessary. 135 // - (false, nil, err) if the initial fetch failed. 136 func (s *Slot) initiateFetch(ctx context.Context, fetcher Fetcher, now time.Time) (bool, any, error) { 137 s.lock.Lock() 138 defer s.lock.Unlock() 139 140 // A cached value exists and it is still fresh? Return it right away. Someone 141 // refetched it already. 142 if s.initialized && isFresh(s.exp, now) { 143 return false, s.current, nil 144 } 145 146 // Fetching the value for the first time ever? Do it under the lock because 147 // there's nothing to return yet. All goroutines would have to wait for this 148 // initial fetch to complete. They'll all block on s.lock.RLock() in Get(...). 149 if !s.initialized { 150 result, exp, err := fetcher(nil) 151 if err != nil { 152 return false, nil, err 153 } 154 s.initialized = true 155 s.current = result 156 s.exp = setExpiry(ctx, exp) 157 return false, s.current, nil 158 } 159 160 // We have a cached copy but it has expired. Maybe some other goroutine is 161 // fetching it already? Return the cached stale copy if so. 162 if s.fetching { 163 return false, s.current, nil 164 } 165 166 // No one is fetching the value now, we should do it. Make other goroutines 167 // know we'll be fetching. Return the current value as well, to pass it to 168 // the fetch callback. 169 s.fetching = true 170 return true, s.current, nil 171 } 172 173 // finishFetch switches the Slot back to "not fetching" state, remembering the 174 // fetched value. 175 // 176 // 'completed' is false if the fetch panicked. 177 func (s *Slot) finishFetch(completed bool, result any, exp time.Time) { 178 s.lock.Lock() 179 defer s.lock.Unlock() 180 s.fetching = false 181 if completed { 182 s.current = result 183 s.exp = exp 184 } 185 } 186 187 func (s *Slot) retryDelay() time.Duration { 188 if s.RetryDelay == 0 { 189 return 5 * time.Second 190 } 191 return s.RetryDelay 192 } 193 194 func isFresh(exp, now time.Time) bool { 195 return exp.IsZero() || now.Before(exp) 196 } 197 198 func setExpiry(ctx context.Context, exp time.Duration) time.Time { 199 switch { 200 case exp == 0: 201 return time.Time{} 202 case exp < 0: // including ExpiresImmediately 203 return clock.Now(ctx) // this would make isFresh return false 204 } 205 return clock.Now(ctx).Add(exp) 206 }