Skip to content

Commit 2f2d7a2

Browse files
committed
Implemented query caching for searches
This commit implements a possibly multi-user cache for long running queries (currently the threshold is 0.4 seconds) We use the max (message id, topic id) as a marker in the cache keys, because these lookups are cheap. This means the cache remains valid for multiple users as long as no new message arrives matching the search criteria, and at time we automatically generate a new cache entry, while existing views can still use the old cache record.
1 parent 23b4f60 commit 2f2d7a2

File tree

4 files changed

+185
-6
lines changed

4 files changed

+185
-6
lines changed

app/controllers/topics_controller.rb

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,19 @@ def read_all
9090
def search
9191
@search_query = params[:q].to_s.strip
9292

93-
base_query = topics_base_query(search_query: @search_query)
94-
95-
apply_cursor_pagination(base_query)
96-
@new_topics_count = 0
93+
if @search_query.present?
94+
load_cached_search_results
95+
else
96+
base_query = topics_base_query(search_query: @search_query)
97+
apply_cursor_pagination(base_query)
98+
@new_topics_count = 0
99+
end
97100

98101
preload_participation_flags if user_signed_in?
99102

100103
respond_to do |format|
101104
format.html
102-
format.turbo_stream { render :index }
105+
format.turbo_stream { render :search }
103106
end
104107
end
105108

@@ -593,4 +596,79 @@ def load_notes
593596
@notes_by_message[key] << note
594597
end
595598
end
599+
600+
def load_cached_search_results
601+
@viewing_since = viewing_since_param
602+
longpage = params[:longpage].to_i
603+
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)
604+
605+
result = cache.fetch do |limit, offset|
606+
build_search_query(@search_query)
607+
.joins(:messages)
608+
.where(messages: { created_at: ..@viewing_since })
609+
.group('topics.id')
610+
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
611+
.order('MAX(messages.created_at) DESC, topics.id DESC')
612+
.limit(limit)
613+
.offset(offset)
614+
.load
615+
end
616+
617+
entries = result[:entries] || []
618+
sliced = slice_cached_entries(entries, params[:cursor])
619+
620+
if sliced[:entries].empty? && entries.size >= SearchResultCache::LONGPAGE_SIZE
621+
longpage += 1
622+
cache = SearchResultCache.new(query: @search_query, scope: "title_body", viewing_since: @viewing_since, longpage: longpage)
623+
next_result = cache.fetch do |limit, offset|
624+
build_search_query(@search_query)
625+
.joins(:messages)
626+
.where(messages: { created_at: ..@viewing_since })
627+
.group('topics.id')
628+
.select('topics.id, topics.creator_id, MAX(messages.created_at) as last_activity')
629+
.order('MAX(messages.created_at) DESC, topics.id DESC')
630+
.limit(limit)
631+
.offset(offset)
632+
.load
633+
end
634+
entries = next_result[:entries] || []
635+
sliced = slice_cached_entries(entries, params[:cursor])
636+
end
637+
638+
@current_longpage = longpage
639+
@topics = hydrate_topics_from_entries(sliced[:entries])
640+
@topics = [] unless @topics
641+
@new_topics_count = 0
642+
end
643+
644+
def slice_cached_entries(entries, cursor_param)
645+
return { entries: entries.first(25) } unless cursor_param.present?
646+
647+
cursor_time_str, cursor_id_str = cursor_param.split('_')
648+
cursor_time = Time.zone.parse(cursor_time_str)
649+
cursor_id = cursor_id_str.to_i
650+
651+
start_index = entries.find_index do |entry|
652+
entry_time = entry[:last_activity]
653+
next false unless entry_time
654+
(entry_time < cursor_time) || (entry_time == cursor_time && entry[:id].to_i < cursor_id)
655+
end
656+
657+
start_index ||= entries.size
658+
{ entries: entries.drop(start_index).first(25) }
659+
end
660+
661+
def hydrate_topics_from_entries(entries)
662+
ids = entries.map { |e| e[:id] }
663+
return [] if ids.empty?
664+
665+
topics_map = Topic.includes(:creator).where(id: ids).index_by(&:id)
666+
entries.filter_map do |entry|
667+
topic = topics_map[entry[:id]]
668+
next unless topic
669+
last_activity = entry[:last_activity]
670+
topic.define_singleton_method(:last_activity) { last_activity }
671+
topic
672+
end
673+
end
596674
end
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
require "digest"
2+
3+
class SearchResultCache
4+
CACHE_VERSION = "v1"
5+
LONGPAGE_SIZE = 1000
6+
CACHE_TTL = 6.hours
7+
CACHE_THRESHOLD_SECONDS = 0.4
8+
9+
def initialize(query:, scope:, viewing_since:, longpage: 0, cache: Rails.cache)
10+
@query = query.to_s
11+
@scope = scope.to_s
12+
@viewing_since = viewing_since
13+
@longpage = [longpage.to_i, 0].max
14+
@cache = cache
15+
end
16+
17+
def fetch
18+
watermarks = compute_watermarks
19+
cached = read_cached(watermarks)
20+
return cached if cached
21+
22+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23+
entries = yield(LONGPAGE_SIZE, offset_for_longpage)
24+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
25+
26+
payload = serialize_entries(entries).merge(watermarks:, cached_at: Time.current)
27+
28+
if duration > CACHE_THRESHOLD_SECONDS
29+
write_cached(watermarks, payload)
30+
end
31+
32+
payload
33+
end
34+
35+
private
36+
37+
def offset_for_longpage
38+
LONGPAGE_SIZE * @longpage
39+
end
40+
41+
def read_cached(watermarks)
42+
@cache.read(cache_key(watermarks))
43+
end
44+
45+
def write_cached(watermarks, payload)
46+
@cache.write(cache_key(watermarks), payload, expires_in: CACHE_TTL)
47+
end
48+
49+
def cache_key(watermarks)
50+
watermark_part = watermarks.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}=#{v || 0}" }.join(":")
51+
query_hash = Digest::SHA1.hexdigest(@query)
52+
key = [
53+
"search",
54+
CACHE_VERSION,
55+
"scope", @scope,
56+
"q", query_hash,
57+
"wm", watermark_part,
58+
"lp", @longpage
59+
].join(":")
60+
File.open("cachelog.txt", 'a') do |f|
61+
f.puts "CACHE KEY: #{key}"
62+
end
63+
key
64+
end
65+
66+
def serialize_entries(entries)
67+
{
68+
entries: entries.map do |row|
69+
{
70+
id: row.id,
71+
last_activity: row.try(:last_activity)&.to_time || row.try(:created_at)&.to_time
72+
}
73+
end
74+
}
75+
end
76+
77+
def compute_watermarks
78+
case @scope
79+
when "title_body"
80+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(@query)}%"
81+
title_max = Topic.where("title ILIKE ?", pattern)
82+
.where("created_at <= ?", @viewing_since)
83+
.maximum(:id) || 0
84+
body_max = Message.where("body ILIKE ?", pattern)
85+
.where("created_at <= ?", @viewing_since)
86+
.maximum(:id) || 0
87+
{ title_max_id: title_max, body_max_id: body_max }
88+
else
89+
{}
90+
end
91+
end
92+
end

app/views/topics/search.html.slim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
- if @topics.size == 25
2222
- last_topic = @topics.last
2323
- cursor = "#{last_topic.last_activity.iso8601}_#{last_topic.id}"
24-
= turbo_frame_tag "pagination", src: search_topics_path(q: @search_query, cursor: cursor, viewing_since: @viewing_since.iso8601, format: :turbo_stream), loading: :lazy do
24+
= turbo_frame_tag "pagination", src: search_topics_path(q: @search_query, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
2525
.loading-indicator Loading more results...
2626
- else
2727
.no-results
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
= turbo_stream.append "topics" do
2+
= render partial: "topics", locals: { topics: @topics }
3+
4+
- if @topics.size == 25
5+
- last_topic = @topics.last
6+
- cursor = "#{last_topic.last_activity.iso8601}_#{last_topic.id}"
7+
= turbo_stream.replace "pagination" do
8+
= turbo_frame_tag "pagination", src: search_topics_path(q: @search_query, cursor: cursor, viewing_since: @viewing_since.iso8601, longpage: @current_longpage, format: :turbo_stream), loading: :lazy do
9+
.loading-indicator Loading more results...

0 commit comments

Comments
 (0)