New Upstream Snapshot - golang-github-karrick-goswarm

Ready changes

Summary

Merged new upstream version: 1.10.0 (was: 1.4.7).

Resulting package

Built on 2022-11-10T06:46 (took 6m37s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-snapshots golang-github-karrick-goswarm-dev

Lintian Result

Diff

diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index daf913b..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,24 +0,0 @@
-# Compiled Object files, Static and Dynamic libs (Shared Objects)
-*.o
-*.a
-*.so
-
-# Folders
-_obj
-_test
-
-# Architecture specific extensions/prefixes
-*.[568vq]
-[568vq].out
-
-*.cgo1.go
-*.cgo2.c
-_cgo_defun.c
-_cgo_gotypes.go
-_cgo_export.*
-
-_testmain.go
-
-*.exe
-*.test
-*.prof
diff --git a/debian/changelog b/debian/changelog
index 51af554..174c74d 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+golang-github-karrick-goswarm (1.10.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Thu, 10 Nov 2022 06:41:11 -0000
+
 golang-github-karrick-goswarm (1.4.7-1) unstable; urgency=medium
 
   * Initial release. (Closes: #888673)
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4a7adbc
--- /dev/null
+++ b/go.mod
@@ -0,0 +1 @@
+module github.com/karrick/goswarm
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/simple.go b/simple.go
index a22b2ef..0c7a5aa 100644
--- a/simple.go
+++ b/simple.go
@@ -23,6 +23,22 @@ type Simple struct {
 	halt       chan struct{}
 	closeError chan error
 	gcFlag     int32
+	stats      Stats
+}
+
+// Stats contains various cache statistics.
+type Stats struct {
+	Count        int64 // Count represents the number of items in the cache.
+	Creates      int64 // Creates represents how many new cache items were created since the previous Stats call.
+	Deletes      int64 // Deletes represents how many Delete calls were made since the previous Stats call. Note this counts when Delete is called with a key that is not in the cache.
+	Evictions    int64 // Evictions represents how many key-value pairs were evicted since the previous Stats call.
+	Hits         int64 // Hits represents how many Load and Query calls found and returned a Fresh item.
+	LookupErrors int64 // LookupErrors represents the number of Lookup invocations that returned an error since the previous Stats call.
+	Misses       int64 // Misses represents how many Load and Query calls either did not find the item, or found an expired item.
+	Queries      int64 // Queries represents how many Load and Query calls were made since the previous Stats call.
+	Stales       int64 // Stales represents how many Load and Query calls found and returned a Stale item.
+	Stores       int64 // Stores represents how many Store calls were made since the previous Stats call.
+	Updates      int64 // Updates represents how many Update calls were made since the previous Stats call.
 }
 
 // NewSimple returns Swarm that attempts to respond to Query methods by
@@ -108,6 +124,8 @@ func (s *Simple) Close() error {
 
 // Delete removes the key and associated value from the data map.
 func (s *Simple) Delete(key string) {
+	atomic.AddInt64(&s.stats.Deletes, 1)
+
 	s.lock.RLock()
 	_, ok := s.data[key]
 	s.lock.RUnlock()
@@ -177,7 +195,7 @@ func (s *Simple) GC() {
 			if av := atv.av.Load(); av != nil {
 				allPairs <- gcPair{
 					key:    key,
-					doomed: av.(*TimedValue).isExpired(now),
+					doomed: av.(*TimedValue).IsExpiredAt(now),
 				}
 			}
 		}(key, atv, allPairs)
@@ -217,18 +235,22 @@ loop:
 	for _, key := range doomed {
 		delete(s.data, key)
 	}
+	atomic.AddInt64(&s.stats.Evictions, int64(len(doomed)))
 	s.lock.Unlock()
 }
 
 // Load returns the value associated with the specified key, and a boolean value
 // indicating whether or not the key was found in the map.
 func (s *Simple) Load(key string) (interface{}, bool) {
+	atomic.AddInt64(&s.stats.Queries, 1)
+
 	// Do not want to use getOrCreateLockingTimeValue, because there's no reason
 	// to create ATV if key is not present in data map.
 	s.lock.RLock()
 	atv, ok := s.data[key]
 	s.lock.RUnlock()
 	if !ok {
+		atomic.AddInt64(&s.stats.Misses, 1)
 		return nil, false
 	}
 
@@ -237,9 +259,60 @@ func (s *Simple) Load(key string) (interface{}, bool) {
 		// Element either recently erased by another routine while this method
 		// was waiting for element lock above, or has not been populated by
 		// fetch, in which case the value is not really there yet.
+		atomic.AddInt64(&s.stats.Misses, 1)
 		return nil, false
 	}
-	return av.(*TimedValue).Value, true
+
+	now := time.Now()
+	tv := av.(*TimedValue)
+
+	if tv.IsExpiredAt(now) {
+		atomic.AddInt64(&s.stats.Misses, 1)
+		return nil, false
+	}
+	if tv.IsStaleAt(now) {
+		atomic.AddInt64(&s.stats.Stales, 1)
+		return tv.Value, true
+	}
+	atomic.AddInt64(&s.stats.Hits, 1)
+	return tv.Value, true
+}
+
+// LoadTimedValue returns the TimedValue associated with the specified key, or
+// false if the key is not found in the map.
+func (s *Simple) LoadTimedValue(key string) *TimedValue {
+	atomic.AddInt64(&s.stats.Queries, 1)
+
+	// Do not want to use getOrCreateLockingTimeValue, because there's no reason
+	// to create ATV if key is not present in data map.
+	s.lock.RLock()
+	atv, ok := s.data[key]
+	s.lock.RUnlock()
+	if !ok {
+		atomic.AddInt64(&s.stats.Misses, 1)
+		return nil
+	}
+
+	av := atv.av.Load()
+	if av == nil {
+		// Element either recently erased by another routine while this method
+		// was waiting for element lock above, or has not been populated by
+		// fetch, in which case the value is not really there yet.
+		atomic.AddInt64(&s.stats.Misses, 1)
+		return nil
+	}
+
+	now := time.Now()
+	tv := av.(*TimedValue)
+
+	if tv.IsExpiredAt(now) {
+		atomic.AddInt64(&s.stats.Misses, 1)
+	} else if tv.IsStaleAt(now) {
+		atomic.AddInt64(&s.stats.Stales, 1)
+	} else {
+		atomic.AddInt64(&s.stats.Hits, 1)
+	}
+	return tv
 }
 
 // Query loads the value associated with the specified key from the data
@@ -249,27 +322,40 @@ func (s *Simple) Load(key string) (interface{}, bool) {
 // lookup of a new value is triggered, then the new value is stored and
 // returned.
 func (s *Simple) Query(key string) (interface{}, error) {
+	atomic.AddInt64(&s.stats.Queries, 1)
+
 	atv := s.getOrCreateAtomicTimedValue(key)
 	av := atv.av.Load()
 	if av == nil {
+		atomic.AddInt64(&s.stats.Misses, 1)
 		tv := s.update(key, atv)
 		return tv.Value, tv.Err
-	} else {
-		now := time.Now()
-		tv := av.(*TimedValue)
-		if tv.isExpired(now) {
-			tv = s.update(key, atv)
-		} else if tv.isStale(now) {
-			// If no other goroutine is looking up this value, spin one off
-			if atomic.CompareAndSwapInt32(&atv.pending, 0, 1) {
-				go func() {
-					defer atomic.StoreInt32(&atv.pending, 0)
-					_ = s.update(key, atv)
-				}()
-			}
+	}
+
+	now := time.Now()
+	tv := av.(*TimedValue)
+
+	if tv.IsExpiredAt(now) {
+		// Expired is considered a blocking miss.
+		atomic.AddInt64(&s.stats.Misses, 1)
+		tv := s.update(key, atv)
+		return tv.Value, tv.Err
+	}
+
+	if tv.IsStaleAt(now) {
+		// If no other goroutine is looking up this value, spin one off.
+		if atomic.CompareAndSwapInt32(&atv.pending, 0, 1) {
+			go func() {
+				defer atomic.StoreInt32(&atv.pending, 0)
+				_ = s.update(key, atv)
+			}()
 		}
+		atomic.AddInt64(&s.stats.Stales, 1)
 		return tv.Value, tv.Err
 	}
+
+	atomic.AddInt64(&s.stats.Hits, 1)
+	return tv.Value, tv.Err
 }
 
 // Range invokes specified callback function for each non-expired key in the
@@ -300,16 +386,79 @@ func (s *Simple) Range(callback func(key string, value *TimedValue)) {
 	s.lock.RUnlock()
 }
 
+// RangeBreak invokes specified callback function for each non-expired key in
+// the data map. Each key-value pair is independently locked until the callback
+// function invoked with the specified key returns. This method does not block
+// access to the Simple instance, allowing keys to be added and removed like
+// normal even while the callbacks are running. When the callback returns true,
+// this function performs an early termination of enumerating the cache,
+// returning true it its caller.
+func (s *Simple) RangeBreak(callback func(key string, value *TimedValue) bool) bool {
+	// Need to have read lock while enumerating key-value pairs from map
+	s.lock.RLock()
+	for key, atv := range s.data {
+		// Now that we have a key-value pair from the map, we can release the
+		// map's lock to prevent blocking other routines that need it.
+		s.lock.RUnlock()
+
+		if av := atv.av.Load(); av != nil {
+			// We have an element. If it's not yet expired, invoke the user's
+			// callback with the key and value.
+			if tv := av.(*TimedValue); !tv.IsExpired() {
+				if callback(key, tv) {
+					return true
+				}
+			}
+		}
+
+		// After callback is done with element, re-acquire map-level lock before
+		// we grab the next key-value pair from the map.
+		s.lock.RLock()
+	}
+	s.lock.RUnlock()
+	return false
+}
+
+// Stats returns a snapshot of the cache's statistics. Note all statistics will
+// be reset when this method is invoked, allowing the client to determine the
+// number of each respective events that have taken place since the previous
+// time this method was invoked.
+func (s *Simple) Stats() Stats {
+	s.lock.RLock()
+	count := int64(len(s.data))
+	s.lock.RUnlock()
+
+	return Stats{
+		Count:        count,
+		Creates:      atomic.SwapInt64(&s.stats.Creates, 0),
+		Deletes:      atomic.SwapInt64(&s.stats.Deletes, 0),
+		Evictions:    atomic.SwapInt64(&s.stats.Evictions, 0),
+		Hits:         atomic.SwapInt64(&s.stats.Hits, 0),
+		LookupErrors: atomic.SwapInt64(&s.stats.LookupErrors, 0),
+		Misses:       atomic.SwapInt64(&s.stats.Misses, 0),
+		Queries:      atomic.SwapInt64(&s.stats.Queries, 0),
+		Stales:       atomic.SwapInt64(&s.stats.Stales, 0),
+		Stores:       atomic.SwapInt64(&s.stats.Stores, 0),
+		Updates:      atomic.SwapInt64(&s.stats.Updates, 0),
+	}
+}
+
 // Store saves the key-value pair to the cache, overwriting whatever was
 // previously stored.
 func (s *Simple) Store(key string, value interface{}) {
+	atomic.AddInt64(&s.stats.Stores, 1)
 	atv := s.getOrCreateAtomicTimedValue(key)
+
+	// NOTE: Below invocation ignores the provided durations when value is
+	// already a TimedValue.
 	tv := newTimedValue(value, nil, s.config.GoodStaleDuration, s.config.GoodExpiryDuration)
+
 	atv.av.Store(tv)
 }
 
 // Update forces an update of the value associated with the specified key.
 func (s *Simple) Update(key string) {
+	atomic.AddInt64(&s.stats.Updates, 1)
 	atv := s.getOrCreateAtomicTimedValue(key)
 	s.update(key, atv)
 }
@@ -341,6 +490,7 @@ func (s *Simple) getOrCreateAtomicTimedValue(key string) *atomicTimedValue {
 		if !ok {
 			atv = new(atomicTimedValue)
 			s.data[key] = atv
+			atomic.AddInt64(&s.stats.Creates, 1)
 		}
 		s.lock.Unlock()
 	}
@@ -351,19 +501,17 @@ func (s *Simple) getOrCreateAtomicTimedValue(key string) *atomicTimedValue {
 // the update is successful, it stores the value in the TimedValue associated
 // with the key.
 func (s *Simple) update(key string, atv *atomicTimedValue) *TimedValue {
-	staleDuration := s.config.GoodStaleDuration
-	expiryDuration := s.config.GoodExpiryDuration
-
 	value, err := s.config.Lookup(key)
 	if err == nil {
-		tv := newTimedValue(value, err, staleDuration, expiryDuration)
+		tv := newTimedValue(value, nil, s.config.GoodStaleDuration, s.config.GoodExpiryDuration)
 		atv.av.Store(tv)
 		return tv
 	}
 
 	// lookup gave us an error
-	staleDuration = s.config.BadStaleDuration
-	expiryDuration = s.config.BadExpiryDuration
+	atomic.AddInt64(&s.stats.LookupErrors, 1)
+	staleDuration := s.config.BadStaleDuration
+	expiryDuration := s.config.BadExpiryDuration
 
 	// new error overwrites previous error, and also used when initial value
 	av := atv.av.Load()
diff --git a/simple_test.go b/simple_test.go
index 7946423..9b947cc 100644
--- a/simple_test.go
+++ b/simple_test.go
@@ -12,8 +12,8 @@ import (
 	"time"
 )
 
-func ensureErrorL(t *testing.T, swr *Simple, key, expectedError string) {
-	value, err := swr.Query(key)
+func ensureErrorL(t *testing.T, querier Querier, key, expectedError string) {
+	value, err := querier.Query(key)
 	if value != nil {
 		t.Errorf("Actual: %v; Expected: %v", value, nil)
 	}
@@ -22,8 +22,8 @@ func ensureErrorL(t *testing.T, swr *Simple, key, expectedError string) {
 	}
 }
 
-func ensureValueL(t *testing.T, swr *Simple, key string, expectedValue uint64) {
-	value, err := swr.Query(key)
+func ensureValueL(t *testing.T, querier Querier, key string, expectedValue uint64) {
+	value, err := querier.Query(key)
 	if value.(uint64) != expectedValue {
 		t.Errorf("Actual: %d; Expected: %d", value, expectedValue)
 	}
@@ -37,7 +37,7 @@ func ensureValueL(t *testing.T, swr *Simple, key string, expectedValue uint64) {
 func TestSimpleSynchronousLookupWhenMiss(t *testing.T) {
 	var invoked uint64
 	swr, err := NewSimple(&Config{Lookup: func(_ string) (interface{}, error) {
-		atomic.AddUint64(&invoked, 1)
+		invoked++
 		return uint64(42), nil
 	}})
 	if err != nil {
@@ -47,7 +47,7 @@ func TestSimpleSynchronousLookupWhenMiss(t *testing.T) {
 
 	ensureValueL(t, swr, "miss", 42)
 
-	if actual, expected := atomic.AddUint64(&invoked, 0), uint64(1); actual != expected {
+	if actual, expected := invoked, uint64(1); actual != expected {
 		t.Errorf("Actual: %d; Expected: %d", actual, expected)
 	}
 }
@@ -79,11 +79,54 @@ func TestSimpleNoStaleExpireNoLookupWhenBeforeExpire(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that expires one minute in the future
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Expiry: time.Now().Add(time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Expiry: now.Add(time.Minute)})
 
 	ensureValueL(t, swr, "hit", 13)
 }
 
+func TestSimpleStaleExpireLoadReturnsFalse(t *testing.T) {
+	swr, err := NewSimple(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() { _ = swr.Close() }()
+
+	now := time.Now()
+	swr.Store("expired", &TimedValue{Value: uint64(42), Created: now, Expiry: now.Add(-time.Minute)})
+
+	value, ok := swr.Load("expired")
+
+	if got, want := ok, false; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+
+	if value != nil {
+		t.Errorf("GOT: %v; WANT: %v", value, nil)
+	}
+}
+
+func TestSimpleStaleExpireLoadTimedValueReturnsExpiredValue(t *testing.T) {
+	swr, err := NewSimple(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() { _ = swr.Close() }()
+
+	now := time.Now()
+	swr.Store("expired", &TimedValue{Value: uint64(42), Created: now, Expiry: now.Add(-time.Minute)})
+
+	tv := swr.LoadTimedValue("expired")
+
+	if got, want := tv.IsExpired(), true; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+
+	if got, want := tv.Value, uint64(42); got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+}
+
 func TestSimpleNoStaleExpireSynchronousLookupWhenAfterExpire(t *testing.T) {
 	var invoked uint64
 	swr, err := NewSimple(&Config{Lookup: func(_ string) (interface{}, error) {
@@ -96,7 +139,8 @@ func TestSimpleNoStaleExpireSynchronousLookupWhenAfterExpire(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that expired one minute ago
-	swr.Store("hit", &TimedValue{Value: uint64(42), Err: nil, Expiry: time.Now().Add(-time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(42), Err: nil, Created: now, Expiry: now.Add(-time.Minute)})
 
 	ensureValueL(t, swr, "hit", 42)
 
@@ -117,7 +161,8 @@ func TestSimpleStaleNoExpireNoLookupWhenBeforeStale(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that goes stale one minute in the future
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Stale: time.Now().Add(time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Stale: now.Add(time.Minute)})
 
 	ensureValueL(t, swr, "hit", 13)
 }
@@ -137,7 +182,8 @@ func TestSimpleStaleNoExpireSynchronousLookupOnlyOnceWhenAfterStale(t *testing.T
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that went stale one minute ago
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Stale: time.Now().Add(-time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Stale: now.Add(-time.Minute)})
 
 	wg.Add(1)
 	ensureValueL(t, swr, "hit", 13)
@@ -165,7 +211,8 @@ func TestSimpleStaleExpireNoLookupWhenBeforeStale(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that goes stale one minute in the future and expires one hour in the future
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Stale: time.Now().Add(time.Minute), Expiry: time.Now().Add(time.Hour)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Stale: now.Add(time.Minute), Expiry: now.Add(time.Hour)})
 
 	ensureValueL(t, swr, "hit", 13)
 }
@@ -184,7 +231,8 @@ func TestSimpleStaleExpireSynchronousLookupWhenAfterStaleAndBeforeExpire(t *test
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that went stale one minute ago and expires one minute in the future
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Stale: time.Now().Add(-time.Minute), Expiry: time.Now().Add(time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Stale: now.Add(-time.Minute), Expiry: now.Add(time.Minute)})
 
 	// expect to receive the old value back immediately, then expect lookup to be asynchronously invoked
 	wg.Add(1)
@@ -212,7 +260,8 @@ func TestSimpleStaleExpireSynchronousLookupWhenAfterExpire(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that went stale one hour ago and expired one minute ago
-	swr.Store("hit", &TimedValue{Value: uint64(42), Err: nil, Stale: time.Now().Add(-time.Hour), Expiry: time.Now().Add(-time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(42), Err: nil, Created: now, Stale: now.Add(-time.Hour), Expiry: now.Add(-time.Minute)})
 
 	ensureValueL(t, swr, "hit", 42)
 
@@ -235,7 +284,8 @@ func TestSimpleErrDoesNotReplaceStaleValue(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that went stale one minute ago
-	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Stale: time.Now().Add(-time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: uint64(13), Err: nil, Created: now, Stale: now.Add(-time.Minute)})
 
 	wg.Add(1)
 	ensureValueL(t, swr, "hit", 13)
@@ -266,7 +316,8 @@ func TestSimpleNewErrReplacesOldError(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value that went stale one minute ago
-	swr.Store("hit", &TimedValue{Value: nil, Err: errors.New("original error"), Stale: time.Now().Add(-time.Minute)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: nil, Err: errors.New("original error"), Created: now, Stale: now.Add(-time.Minute)})
 
 	wg.Add(1)
 	ensureErrorL(t, swr, "hit", "new error")
@@ -292,7 +343,8 @@ func TestSimpleErrReplacesExpiredValue(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	// NOTE: storing a value is already stale, but will expire during the fetch
-	swr.Store("hit", &TimedValue{Value: nil, Err: errors.New("original error"), Stale: time.Now().Add(-time.Hour), Expiry: time.Now().Add(5 * time.Millisecond)})
+	now := time.Now()
+	swr.Store("hit", &TimedValue{Value: nil, Err: errors.New("original error"), Created: now, Stale: now.Add(-time.Hour), Expiry: now.Add(5 * time.Millisecond)})
 
 	wg.Add(1)
 	ensureErrorL(t, swr, "hit", "original error")
@@ -317,13 +369,26 @@ func TestSimpleRange(t *testing.T) {
 	defer func() { _ = swr.Close() }()
 
 	swr.Store("no expiry", "shall not expire")
-	swr.Store("stale value", TimedValue{Value: "stale value", Stale: time.Now().Add(-time.Minute)})
-	swr.Store("expired value", TimedValue{Value: "expired value", Expiry: time.Now().Add(-time.Minute)})
+	swr.Store("expired value", TimedValue{Value: "expired value", Created: time.Now(), Expiry: time.Now().Add(-time.Minute)})
+	swr.Store("stale value", TimedValue{Value: "stale value", Created: time.Now(), Stale: time.Now().Add(-time.Minute)})
+	swr.Store("will update expiry", "soon to be expired")
+
+	swr.Store("will update stale", TimedValue{Value: "stale value", Created: time.Now(), Stale: time.Now().Add(-time.Minute)})
+	// make sure already stale
+	if got, want := swr.LoadTimedValue("will update stale").IsStale(), true; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
 
 	called := make(map[string]struct{})
 	swr.Range(func(key string, value *TimedValue) {
 		called[key] = struct{}{}
 		swr.Store(strconv.Itoa(rand.Intn(50)), "make sure we can invoke methods that require locking")
+		switch key {
+		case "will update stale":
+			value.Stale = time.Now().Add(time.Minute)
+		case "will update expiry":
+			value.Expiry = time.Now().Add(-time.Minute)
+		}
 	})
 
 	if _, ok := called["no expiry"]; !ok {
@@ -335,6 +400,46 @@ func TestSimpleRange(t *testing.T) {
 	if _, ok := called["expired value"]; ok {
 		t.Errorf("Actual: %#v; Expected: %#v", ok, false)
 	}
+	if _, ok := called["will update stale"]; !ok {
+		t.Errorf("Actual: %#v; Expected: %#v", ok, true)
+	}
+	if got, want := swr.LoadTimedValue("will update stale").IsStale(), false; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+	if got, want := swr.LoadTimedValue("will update expiry").IsExpired(), true; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+
+	swr.Store("ensure range released top level lock", struct{}{})
+}
+
+func TestSimpleRangeBreak(t *testing.T) {
+	swr, err := NewSimple(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() { _ = swr.Close() }()
+
+	swr.Store("alpha", 1)
+	swr.Store("bravo", 2)
+	swr.Store("charlie", 3)
+	swr.Store("delta", 4)
+
+	called := make(map[string]struct{})
+	terminated := swr.RangeBreak(func(key string, value *TimedValue) bool {
+		called[key] = struct{}{}
+		if key == "charlie" {
+			return true
+		}
+		return false
+	})
+
+	if got, want := terminated, true; got != want {
+		t.Errorf("GOT: %v; WANT: %v", got, want)
+	}
+	if _, ok := called["charlie"]; !ok {
+		t.Errorf("Actual: %#v; Expected: %#v", ok, true)
+	}
 
 	swr.Store("ensure range released top level lock", struct{}{})
 }
@@ -384,3 +489,401 @@ func TestSimpleGC(t *testing.T) {
 		t.Errorf("Actual: %s; Expected: %s", actual, expected)
 	}
 }
+
+func TestStats(t *testing.T) {
+	t.Run("query", func(t *testing.T) {
+		var haveLookupFail bool
+
+		swr, err := NewSimple(&Config{
+			Lookup: func(key string) (interface{}, error) {
+				if haveLookupFail {
+					return nil, errors.New("lookup failure")
+				}
+				time.Sleep(10 * time.Millisecond)
+				return key, nil
+			},
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Get stats before cache methods invoked.
+		stats := swr.Stats()
+		if got, want := stats.Count, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Creates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Deletes, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Evictions, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.LookupErrors, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Queries, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Hits, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Misses, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stales, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stores, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Updates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+
+		// Invoke Query with key not yet in cache.
+		_, err = swr.Query("foo")
+		if got, want := err, error(nil); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+
+		// Get stats after new key-value pair added.
+		stats = swr.Stats()
+		if got, want := stats.Count, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Creates, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Deletes, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Evictions, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.LookupErrors, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Queries, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Hits, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Misses, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stales, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stores, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Updates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+
+		// Invoke Query with key already in cache.
+		_, err = swr.Query("foo")
+		if got, want := err, error(nil); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+
+		// Get stats after new key-value pair added.
+		stats = swr.Stats()
+		if got, want := stats.Count, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Creates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Deletes, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Evictions, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.LookupErrors, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Queries, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Hits, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Misses, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stales, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stores, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Updates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+
+		// Invoke Query with key already in cache.
+		haveLookupFail = true
+		_, err = swr.Query("bar")
+		if err == nil || !strings.Contains(err.Error(), "lookup failure") {
+			t.Errorf("GOT: %v; WANT: %v", err, "lookup failure")
+		}
+
+		// Get stats after new key-value pair added.
+		stats = swr.Stats()
+		if got, want := stats.Count, int64(2); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Creates, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Deletes, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Evictions, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.LookupErrors, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Queries, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Hits, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Misses, int64(1); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stales, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Stores, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+		if got, want := stats.Updates, int64(0); got != want {
+			t.Errorf("GOT: %v; WANT: %v", got, want)
+		}
+	})
+
+	t.Run("load", func(t *testing.T) {
+		t.Run("stale", func(t *testing.T) {
+			swr, err := NewSimple(nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			swr.Store("foo", TimedValue{
+				Value: "foo",
+				Stale: time.Now().Add(-time.Second),
+			})
+			_, ok := swr.Load("foo")
+			if got, want := ok, true; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+
+			stats := swr.Stats()
+
+			if got, want := stats.Count, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Creates, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Deletes, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Evictions, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.LookupErrors, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Queries, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Hits, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Misses, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stales, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stores, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Updates, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+		})
+
+		t.Run("expired", func(t *testing.T) {
+			swr, err := NewSimple(nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			swr.Store("foo", TimedValue{
+				Value:  "foo",
+				Expiry: time.Now().Add(-time.Second),
+			})
+			_, ok := swr.Load("foo")
+			if got, want := ok, false; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+
+			stats := swr.Stats()
+
+			if got, want := stats.Count, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Creates, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Deletes, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Evictions, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.LookupErrors, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Queries, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Hits, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Misses, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stales, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stores, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Updates, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+		})
+	})
+
+	t.Run("load-timed-value", func(t *testing.T) {
+		t.Run("stale", func(t *testing.T) {
+			swr, err := NewSimple(nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			swr.Store("foo", TimedValue{
+				Value:  "foo",
+				Stale:  time.Now().Add(-time.Second),
+				Expiry: time.Now().Add(time.Second),
+			})
+			tv := swr.LoadTimedValue("foo")
+			if got, want := tv.IsStale(), true; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := tv.IsExpired(), false; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+
+			stats := swr.Stats()
+
+			if got, want := stats.Count, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Creates, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Deletes, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Evictions, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.LookupErrors, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Queries, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Hits, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Misses, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stales, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stores, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Updates, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+		})
+
+		t.Run("expired", func(t *testing.T) {
+			swr, err := NewSimple(nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			swr.Store("foo", TimedValue{
+				Value:  "foo",
+				Stale:  time.Now().Add(-time.Second),
+				Expiry: time.Now().Add(-time.Second),
+			})
+			tv := swr.LoadTimedValue("foo")
+			if got, want := tv.IsStale(), true; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := tv.IsExpired(), true; got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+
+			stats := swr.Stats()
+
+			if got, want := stats.Count, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Creates, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Deletes, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Evictions, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.LookupErrors, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Queries, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Hits, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Misses, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stales, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Stores, int64(1); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+			if got, want := stats.Updates, int64(0); got != want {
+				t.Errorf("GOT: %v; WANT: %v", got, want)
+			}
+		})
+	})
+}
diff --git a/timedValue.go b/timedValue.go
index 432ad23..ca0ccf3 100644
--- a/timedValue.go
+++ b/timedValue.go
@@ -5,8 +5,29 @@ import (
 	"time"
 )
 
-// TimedValue couples a value or the error with both a stale and expiry time for the value and
-// error.
+// TimedValueStatus is an enumeration of the states of a TimedValue: Fresh,
+// Stale, or Expired.
+type TimedValueStatus int
+
+const (
+	// Fresh items are not yet Stale, and will be returned immediately on Query
+	// without scheduling an asynchronous Lookup.
+	Fresh TimedValueStatus = iota
+
+	// Stale items have exceeded their Fresh status, and will be returned
+	// immediately on Query, but will also schedule an asynchronous Lookup if a
+	// Lookup for this key is not yet in flight.
+	Stale
+
+	// Expired items have exceeded their Fresh and Stale status, and will be
+	// evicted during the next background cache eviction loop, controlled by
+	// GCPeriodicity parameter, or upon Query, which will cause the Query to
+	// block until a new value can be obtained from the Lookup function.
+	Expired
+)
+
+// TimedValue couples a value or the error with both a stale and expiry time for
+// the value and error.
 type TimedValue struct {
 	// Value stores the datum returned by the lookup function.
 	Value interface{}
@@ -14,64 +35,103 @@ type TimedValue struct {
 	// Err stores the error returned by the lookup function.
 	Err error
 
-	// Stale stores the time at which the value becomes stale. On Query, a stale value will
-	// trigger an asynchronous lookup of a replacement value, and the original value is
-	// returned. A zero-value for Stale implies the value never goes stale, and querying the key
-	// associated for this value will never trigger an asynchronous lookup of a replacement
-	// value.
+	// Stale stores the time at which the value becomes stale. On Query, a stale
+	// value will trigger an asynchronous lookup of a replacement value, and the
+	// original value is returned. A zero-value for Stale implies the value
+	// never goes stale, and querying the key associated for this value will
+	// never trigger an asynchronous lookup of a replacement value.
 	Stale time.Time
 
-	// Expiry stores the time at which the value expires. On Query, an expired value will block
-	// until a synchronous lookup of a replacement value is attempted. Once the lookup returns,
-	// the Query method will return with the new value or the error returned by the lookup
-	// function.
+	// Expiry stores the time at which the value expires. On Query, an expired
+	// value will block until a synchronous lookup of a replacement value is
+	// attempted. Once the lookup returns, the Query method will return with the
+	// new value or the error returned by the lookup function.
 	Expiry time.Time
+
+	// Created stores the time at which the value was created.
+	Created time.Time
 }
 
-// IsExpired returns true if and only if value is expired. A value is expired when its non-zero
-// expiry time is before the current time, or when the value represents an error and expiry time is
-// the time.Time zero-value.
+// IsExpired returns true when the value is expired.
+//
+// A value is expired when its non-zero expiry time is before the current time,
+// or when the value represents an error and expiry time is the time.Time
+// zero-value.
 func (tv *TimedValue) IsExpired() bool {
-	return tv.isExpired(time.Now())
+	return tv.IsExpiredAt(time.Now())
 }
 
-// provided for internal use so we don't need to repeatedly get the current time
-func (tv *TimedValue) isExpired(when time.Time) bool {
+// IsExpiredAt returns true when the value is expired at the specified time.
+//
+// A value is expired when its non-zero expiry time is before the specified
+// time, or when the value represents an error and expiry time is the time.Time
+// zero-value.
+func (tv *TimedValue) IsExpiredAt(when time.Time) bool {
 	if tv.Err == nil {
 		return !tv.Expiry.IsZero() && when.After(tv.Expiry)
 	}
-	// NOTE: When a TimedValue stores an error result, then Expiry and Expiry zero-values imply
-	// the value is immediately expired.
+	// NOTE: When a TimedValue stores an error result, then a zero-value for the
+	// Expiry imply the value is immediately expired.
 	return tv.Expiry.IsZero() || when.After(tv.Expiry)
 }
 
-// IsStale returns true if and only if value is stale. A value is stale when its non-zero stale time
-// is before the current time, or when the value represents an error and stale time is the time.Time
+// IsStale returns true when the value is stale.
+//
+// A value is stale when its non-zero stale time is before the current time, or
+// when the value represents an error and stale time is the time.Time
 // zero-value.
 func (tv *TimedValue) IsStale() bool {
-	return tv.isStale(time.Now())
+	return tv.IsStaleAt(time.Now())
 }
 
-// provided for internal use so we don't need to repeatedly get the current time
-func (tv *TimedValue) isStale(when time.Time) bool {
+// IsStaleAt returns true when the value is stale at the specified time.
+//
+// A value is stale when its non-zero stale time is before the specified time,
+// or when the value represents an error and stale time is the time.Time
+// zero-value.
+func (tv *TimedValue) IsStaleAt(when time.Time) bool {
 	if tv.Err == nil {
 		return !tv.Stale.IsZero() && when.After(tv.Stale)
 	}
-	// NOTE: When a TimedValue stores an error result, then Stale and Expiry zero-values imply
-	// the value is immediately stale.
+	// NOTE: When a TimedValue stores an error result, then a zero-value for the
+	// Stale or Expiry imply the value is immediately stale.
 	return tv.Stale.IsZero() || when.After(tv.Stale)
 }
 
+// Status returns Fresh, Stale, or Exired, depending on the status of the
+// TimedValue item at the current time.
+func (tv *TimedValue) Status() TimedValueStatus {
+	return tv.StatusAt(time.Now())
+}
+
+// StatusAt returns Fresh, Stale, or Exired, depending on the status of the
+// TimedValue item at the specified time.
+func (tv *TimedValue) StatusAt(when time.Time) TimedValueStatus {
+	if tv.IsExpiredAt(when) {
+		return Expired
+	}
+	if tv.IsStaleAt(when) {
+		return Stale
+	}
+	return Fresh
+}
+
 // helper function to wrap non TimedValue items as TimedValue items.
 func newTimedValue(value interface{}, err error, staleDuration, expiryDuration time.Duration) *TimedValue {
 	switch val := value.(type) {
 	case TimedValue:
+		if val.Created.IsZero() {
+			val.Created = time.Now()
+		}
 		return &val
 	case *TimedValue:
+		if val.Created.IsZero() {
+			val.Created = time.Now()
+		}
 		return val
 	default:
 		if staleDuration == 0 && expiryDuration == 0 {
-			return &TimedValue{Value: value, Err: err}
+			return &TimedValue{Value: value, Err: err, Created: time.Now()}
 		}
 		var stale, expiry time.Time
 		now := time.Now()
@@ -81,7 +141,7 @@ func newTimedValue(value interface{}, err error, staleDuration, expiryDuration t
 		if expiryDuration > 0 {
 			expiry = now.Add(expiryDuration)
 		}
-		return &TimedValue{Value: value, Err: err, Stale: stale, Expiry: expiry}
+		return &TimedValue{Value: value, Err: err, Created: time.Now(), Stale: stale, Expiry: expiry}
 	}
 }
 

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/share/gocode/src/github.com/karrick/goswarm/go.mod
-rw-r--r--  root/root   /usr/share/gocode/src/github.com/karrick/goswarm/go.sum

No differences were encountered in the control files

More details

Full run details