Don't use Current.account for search records

It doesn't actually work, and even if we could make it work reliably
we are better off if the records always know to go to the right shard.

It does make the interface a bit more complicated as we need to select
the right shard class with `for(account_id)`.
This commit is contained in:
Donal McBreen
2025-11-25 11:34:41 +00:00
parent 4c4f6957ec
commit b571303385
10 changed files with 63 additions and 66 deletions
+2 -3
View File
@@ -4,10 +4,9 @@ module Card::Searchable
included do included do
include ::Searchable include ::Searchable
has_many :card_search_records, class_name: "Search::Record"
scope :mentioning, ->(query, user:) do scope :mentioning, ->(query, user:) do
joins(:card_search_records).merge(Search::Record.for_query(query, user: user)) search_record_class = Search::Record.for(user.account_id)
joins(search_record_class.card_join).merge(search_record_class.for_query(query, user: user))
end end
end end
+7 -12
View File
@@ -2,9 +2,6 @@ module Searchable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_one :search_record, class_name: "Search::Record", as: :searchable
before_save :ensure_current_account
after_create_commit :create_in_search_index after_create_commit :create_in_search_index
after_update_commit :update_in_search_index after_update_commit :update_in_search_index
after_destroy_commit :remove_from_search_index after_destroy_commit :remove_from_search_index
@@ -15,22 +12,16 @@ module Searchable
end end
private private
def ensure_current_account
unless Current.account
raise "Current.account must be set to save #{self.class.name}"
end
end
def create_in_search_index def create_in_search_index
create_search_record!(search_record_attributes) search_record_class.create!(search_record_attributes)
end end
def update_in_search_index def update_in_search_index
search_record&.update!(search_record_attributes) || create_in_search_index search_record_class.upsert!(search_record_attributes)
end end
def remove_from_search_index def remove_from_search_index
search_record&.destroy search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy
end end
def search_record_attributes def search_record_attributes
@@ -46,6 +37,10 @@ module Searchable
} }
end end
def search_record_class
Search::Record.for(account_id)
end
# Models must implement these methods: # Models must implement these methods:
# - account_id: returns the account id # - account_id: returns the account id
# - search_title: returns title string or nil # - search_title: returns title string or nil
+16
View File
@@ -6,6 +6,22 @@ class Search::Record < ApplicationRecord
validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true
class << self
def upsert!(attributes)
record = find_by(searchable_type: attributes[:searchable_type], searchable_id: attributes[:searchable_id])
if record
record.update!(attributes)
record
else
create!(attributes)
end
end
def card_join
"INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id"
end
end
scope :for_query, ->(query, user:) do scope :for_query, ->(query, user:) do
query = Search::Query.wrap(query) query = Search::Query.wrap(query)
+4
View File
@@ -25,6 +25,10 @@ module Search::Record::SQLite
"snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content", "snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content",
"#{connection.quote(query.terms)} AS query" ] "#{connection.quote(query.terms)} AS query" ]
end end
def for(account_id)
self
end
end end
def card_title def card_title
+15 -8
View File
@@ -4,23 +4,26 @@ module Search::Record::Trilogy
SHARD_COUNT = 16 SHARD_COUNT = 16
included do included do
self.abstract_class = true
before_save :set_account_key, :stem_content before_save :set_account_key, :stem_content
scope :matching, ->(query, account_id) do scope :matching, ->(query, account_id) do
full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})" full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})"
where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query) where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query)
end end
SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id|
Class.new(self) do
self.table_name = "search_records_#{shard_id}"
def self.name
"Search::Record"
end
end
end.freeze
end end
class_methods do class_methods do
def compute_table_name
if Current.account
"search_records_#{shard_id_for_account(Current.account.id)}"
else
"search_records_0"
end
end
def shard_id_for_account(account_id) def shard_id_for_account(account_id)
Zlib.crc32(account_id.to_s) % SHARD_COUNT Zlib.crc32(account_id.to_s) % SHARD_COUNT
end end
@@ -28,6 +31,10 @@ module Search::Record::Trilogy
def search_fields(query) def search_fields(query)
"#{connection.quote(query.terms)} AS query" "#{connection.quote(query.terms)} AS query"
end end
def for(account_id)
SHARD_CLASSES[shard_id_for_account(account_id)]
end
end end
def card_title def card_title
+1 -1
View File
@@ -6,7 +6,7 @@ module User::Searcher
end end
def search(terms) def search(terms)
Search::Record.search(terms, user: self) Search::Record.for(account_id).search(terms, user: self)
end end
def remember_search(terms) def remember_search(terms)
+2 -8
View File
@@ -12,16 +12,10 @@ namespace :search do
end end
puts "Reindexing cards..." puts "Reindexing cards..."
Card.find_each do |card| Card.includes(:rich_text_description).find_each(&:reindex)
Current.account = card.account
card.reindex
end
puts "Reindexing comments..." puts "Reindexing comments..."
Comment.find_each do |comment| Comment.includes(:rich_text_body, :card).find_each(&:reindex)
Current.account = comment.account
comment.reindex
end
puts "Done! Reindexed #{Card.count} cards and #{Comment.count} comments." puts "Done! Reindexed #{Card.count} cards and #{Comment.count} comments."
end end
+5 -14
View File
@@ -32,25 +32,16 @@ class Card::SearchableTest < ActiveSupport::TestCase
assert_not_includes results, card_in_other_board assert_not_includes results, card_in_other_board
end end
test "card requires Current.account to be set" do
Current.account = nil
error = assert_raises(RuntimeError) do
@board.cards.create!(title: "Test card", creator: @user)
end
assert_match(/Current.account must be set to save Card/, error.message)
end
test "deleting card removes search record and FTS entry" do test "deleting card removes search record and FTS entry" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Card to delete", creator: @user) card = @board.cards.create!(title: "Card to delete", creator: @user)
# Verify search record exists # Verify search record exists
search_record = Search::Record.find_by(searchable_type: "Card", searchable_id: card.id) search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
assert_not_nil search_record, "Search record should exist after card creation" assert_not_nil search_record, "Search record should exist after card creation"
# For SQLite, verify FTS entry exists # For SQLite, verify FTS entry exists
if Search::Record.connection.adapter_name == "SQLite" if search_record_class.connection.adapter_name == "SQLite"
fts_entry = search_record.search_records_fts fts_entry = search_record.search_records_fts
assert_not_nil fts_entry, "FTS entry should exist" assert_not_nil fts_entry, "FTS entry should exist"
assert_equal card.title, fts_entry.title assert_equal card.title, fts_entry.title
@@ -60,11 +51,11 @@ class Card::SearchableTest < ActiveSupport::TestCase
card.destroy card.destroy
# Verify search record is deleted # Verify search record is deleted
search_record = Search::Record.find_by(searchable_type: "Card", searchable_id: card.id) search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
assert_nil search_record, "Search record should be deleted after card deletion" assert_nil search_record, "Search record should be deleted after card deletion"
# For SQLite, verify FTS entry is deleted # For SQLite, verify FTS entry is deleted
if Search::Record.connection.adapter_name == "SQLite" if search_record_class.connection.adapter_name == "SQLite"
fts_count = Search::Record::SQLite::Fts.where(rowid: card.id).count fts_count = Search::Record::SQLite::Fts.where(rowid: card.id).count
assert_equal 0, fts_count, "FTS entry should be deleted" assert_equal 0, fts_count, "FTS entry should be deleted"
end end
+7 -16
View File
@@ -8,14 +8,15 @@ class Comment::SearchableTest < ActiveSupport::TestCase
end end
test "comment search" do test "comment search" do
search_record_class = Search::Record.for(@user.account_id)
# Comment is indexed on create # Comment is indexed on create
comment = @card.comments.create!(body: "searchable comment text", creator: @user) comment = @card.comments.create!(body: "searchable comment text", creator: @user)
record = Search::Record.find_by(searchable_type: "Comment", searchable_id: comment.id) record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment.id)
assert_not_nil record assert_not_nil record
# Comment is updated in index # Comment is updated in index
comment.update!(body: "updated text") comment.update!(body: "updated text")
record = Search::Record.find_by(searchable_type: "Comment", searchable_id: comment.id) record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment.id)
assert_match /updat/, record.content assert_match /updat/, record.content
# Comment is removed from index on destroy # Comment is removed from index on destroy
@@ -23,17 +24,17 @@ class Comment::SearchableTest < ActiveSupport::TestCase
search_record_id = record.id search_record_id = record.id
# For SQLite, verify FTS entry exists before deletion # For SQLite, verify FTS entry exists before deletion
if Search::Record.connection.adapter_name == "SQLite" if search_record_class.connection.adapter_name == "SQLite"
fts_entry = record.search_records_fts fts_entry = record.search_records_fts
assert_not_nil fts_entry, "FTS entry should exist before comment deletion" assert_not_nil fts_entry, "FTS entry should exist before comment deletion"
end end
comment.destroy comment.destroy
record = Search::Record.find_by(searchable_type: "Comment", searchable_id: comment_id) record = search_record_class.find_by(searchable_type: "Comment", searchable_id: comment_id)
assert_nil record assert_nil record
# For SQLite, verify FTS entry is also deleted # For SQLite, verify FTS entry is also deleted
if Search::Record.connection.adapter_name == "SQLite" if search_record_class.connection.adapter_name == "SQLite"
fts_count = Search::Record::SQLite::Fts.where(rowid: search_record_id).count fts_count = Search::Record::SQLite::Fts.where(rowid: search_record_id).count
assert_equal 0, fts_count, "FTS entry should be deleted after comment deletion" assert_equal 0, fts_count, "FTS entry should be deleted after comment deletion"
end end
@@ -48,18 +49,8 @@ class Comment::SearchableTest < ActiveSupport::TestCase
# Comment stores parent card_id and board_id # Comment stores parent card_id and board_id
new_comment = @card.comments.create!(body: "test comment", creator: @user) new_comment = @card.comments.create!(body: "test comment", creator: @user)
record = Search::Record.find_by(searchable_type: "Comment", searchable_id: new_comment.id) record = search_record_class.find_by(searchable_type: "Comment", searchable_id: new_comment.id)
assert_equal @card.id, record.card_id assert_equal @card.id, record.card_id
assert_equal @board.id, record.board_id assert_equal @board.id, record.board_id
end end
test "comment requires Current.account to be set" do
Current.account = nil
error = assert_raises(RuntimeError) do
@card.comments.create!(body: "Test comment", creator: @user)
end
assert_match(/Current.account must be set to save Comment/, error.message)
end
end end
+4 -4
View File
@@ -9,10 +9,10 @@ class SearchTest < ActiveSupport::TestCase
comment_card = @board.cards.create!(title: "Some card", creator: @user) comment_card = @board.cards.create!(title: "Some card", creator: @user)
comment_card.comments.create!(body: "overflowing text", creator: @user) comment_card.comments.create!(body: "overflowing text", creator: @user)
results = Search::Record.search("layout", user: @user) results = Search::Record.for(@user.account_id).search("layout", user: @user)
assert results.find { |it| it.card_id == card.id } assert results.find { |it| it.card_id == card.id }
results = Search::Record.search("overflowing", user: @user) results = Search::Record.for(@user.account_id).search("overflowing", user: @user)
assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" } assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" }
# Don't include inaccessible boards # Don't include inaccessible boards
@@ -21,13 +21,13 @@ class SearchTest < ActiveSupport::TestCase
accessible_card = @board.cards.create!(title: "searchable content", creator: @user) accessible_card = @board.cards.create!(title: "searchable content", creator: @user)
inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user) inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user)
results = Search::Record.search("searchable", user: @user) results = Search::Record.for(@user.account_id).search("searchable", user: @user)
assert results.find { |it| it.card_id == accessible_card.id } assert results.find { |it| it.card_id == accessible_card.id }
assert_not results.find { |it| it.card_id == inaccessible_card.id } assert_not results.find { |it| it.card_id == inaccessible_card.id }
# Empty board_ids returns no results # Empty board_ids returns no results
user_without_access = User.create!(name: "No Access User", account: @account) user_without_access = User.create!(name: "No Access User", account: @account)
results = Search::Record.search("anything", user: user_without_access) results = Search::Record.for(user_without_access.account_id).search("anything", user: user_without_access)
assert_empty results assert_empty results
end end
end end