diff --git a/app/services/random_topic_selector.rb b/app/services/random_topic_selector.rb new file mode 100644 index 00000000000..ac4945afe1c --- /dev/null +++ b/app/services/random_topic_selector.rb @@ -0,0 +1,75 @@ +class RandomTopicSelector + + BACKFILL_SIZE = 3000 + BACKFILL_LOW_WATER_MARK = 500 + + def self.backfill(category=nil) + + exclude = category.try(:topic_id) + + # don't leak private categories into the "everything" group + user = category ? CategoryFeaturedTopic.fake_admin : nil + + options = { + per_page: SiteSetting.category_featured_topics, + visible: true, + no_definitions: true + } + + options[:except_topic_ids] = [category.topic_id] if exclude + options[:category] = category.id if category + + query = TopicQuery.new(user, options) + results = query.latest_results.order('RANDOM()') + .where(closed: false, archived: false) + .limit(BACKFILL_SIZE) + .reorder('RANDOM()') + .pluck(:id) + + key = cache_key(category) + results.each do |id| + $redis.rpush(key, id) + end + $redis.expire(key, 2.days) + + results + end + + def self.next(count, category=nil) + key = cache_key(category) + + results = [] + + left = count + + while left > 0 + id = $redis.lpop key + break unless id + + results << id.to_i + left -= 1 + end + + backfilled = false + if left > 0 + ids = backfill(category) + backfilled = true + results += ids[0...count] + results.uniq! + results = results[0...count] + end + + if !backfilled && $redis.llen(key) < BACKFILL_LOW_WATER_MARK + Scheduler::Defer.later("backfill") do + backfill(category) + end + end + + results + end + + def self.cache_key(category=nil) + "random_topic_cache_#{category.try(:id)}" + end + +end diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 8057974b747..15503dab1a4 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -414,7 +414,16 @@ class TopicQuery result = result.order("CASE WHEN topics.category_id = #{topic.category_id.to_i} THEN 0 ELSE 1 END") end - result.order("RANDOM()") + # Best effort, it over selects, however if you have a high number + # of muted categories there is tiny chance we will not select enough + # in particular this can happen if current category is empty and tons + # of muted, big edge case + # + # we over select in case cache is stale + max = (count*1.3).to_i + ids = RandomTopicSelector.next(max) + RandomTopicSelector.next(max, topic.category) + + result.where(id: ids) end def suggested_ordering(result, options) diff --git a/spec/services/random_topic_selector_spec.rb b/spec/services/random_topic_selector_spec.rb new file mode 100644 index 00000000000..f0108f28207 --- /dev/null +++ b/spec/services/random_topic_selector_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe RandomTopicSelector do + + it 'can correctly use cache' do + key = RandomTopicSelector.cache_key + + $redis.del key + + 4.times do |t| + $redis.rpush key, t + end + + RandomTopicSelector.next(2).should == [0,1] + RandomTopicSelector.next(2).should == [2,3] + end + + it 'can correctly backfill' do + category = Fabricate(:category) + t1 = Fabricate(:topic, category_id: category.id) + _t2 = Fabricate(:topic, category_id: category.id, visible: false) + _t3 = Fabricate(:topic, category_id: category.id, deleted_at: 1.minute.ago) + t4 = Fabricate(:topic, category_id: category.id) + + RandomTopicSelector.next(5, category).sort.should == [t1.id,t4.id].sort + end +end