shards: cache ranked slice

On a search request we construct and sort a slice of all shards to
search. This change will cache the slice. We do the caching on the read path
to avoid holding the exclusive lock for a long time when we do writes to the
shard map.

The use of a "rankedVersion" is a bit fiddly. Alternatively we could look
making the replace call a batch version to amortize the cost of creating the
ranked slice.

Change-Id: I5272dbb75a4becebb8aa8727d8459d0666cb9bf9
diff --git a/shards/shards.go b/shards/shards.go
index b90c964..108ba4f 100644
--- a/shards/shards.go
+++ b/shards/shards.go
@@ -45,6 +45,9 @@
 	capacity int64
 
 	shards map[string]rankedShard
+
+	rankedVersion uint64
+	ranked        []rankedShard
 }
 
 func newShardedSearcher(n int64) *shardedSearcher {
@@ -315,18 +318,33 @@
 	return s.throttle.Acquire(ctx, 1)
 }
 
-// getShards returns the currently loaded shards. The shards must be
-// accessed under a rlock call. The shards are sorted by decreasing
-// rank.
+// getShards returns the currently loaded shards. The shards must be accessed
+// under a rlock call. The shards are sorted by decreasing rank and should not
+// be mutated.
 func (s *shardedSearcher) getShards() []rankedShard {
+	if len(s.ranked) > 0 {
+		return s.ranked
+	}
+
 	var res []rankedShard
 	for _, sh := range s.shards {
 		res = append(res, sh)
 	}
-	// TODO: precompute this.
 	sort.Slice(res, func(i, j int) bool {
 		return res[i].rank > res[j].rank
 	})
+
+	// Cache ranked. We currently hold a read lock, so start a goroutine which
+	// acquires a write lock to update. Use requiredVersion to ensure our
+	// cached slice is still current after acquiring the write lock.
+	go func(ranked []rankedShard, requiredVersion uint64) {
+		s.lock()
+		if s.rankedVersion == requiredVersion {
+			s.ranked = ranked
+		}
+		s.unlock()
+	}(res, s.rankedVersion)
+
 	return res
 }
 
@@ -376,6 +394,8 @@
 			Searcher: shard,
 		}
 	}
+	s.rankedVersion++
+	s.ranked = nil
 }
 
 func loadShard(fn string) (zoekt.Searcher, error) {