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  }