Compare commits

...

2 Commits

Author SHA1 Message Date
Christian Oelschlegel
5a1ce7c6bb
Merge 59f1353bb0 into 624c024766 2025-09-03 10:07:12 +00:00
Christian Oelschlegel
59f1353bb0 fix(tag): prevent dupl. tags on concurrent inserts
Currently, creating tags concurrently with different case
letters can raise a PostgreSQL unique constraint error
(index_tags_on_name_lower_btree).

This commit normalizes tag names to lowercase and uses
first_or_create! with a rescue for RecordNotUnique to
ensure that only one tag is created even under race conditions.

A test is added to simulate concurrent tag creation
and verify that only a single tag exists and no errors occur.
2025-08-22 18:04:37 +02:00
2 changed files with 29 additions and 2 deletions

View File

@ -112,8 +112,14 @@ class Tag < ApplicationRecord
names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first)
names.map do |(normalized_name, display_name)|
tag = matching_name(normalized_name).first || create(name: normalized_name,
display_name: display_name.gsub(HASHTAG_INVALID_CHARS_RE, ''))
tag = begin
matching_name(normalized_name).first_or_create!(
name: normalized_name,
display_name: display_name.gsub(HASHTAG_INVALID_CHARS_RE, '')
)
rescue ActiveRecord::RecordNotUnique
find_normalized(normalized_name)
end
yield tag if block_given?

View File

@ -261,6 +261,27 @@ RSpec.describe Tag do
end
end
describe '.find_or_create_by_names_race_condition' do
it 'handles simultaneous inserts of the same tag in different cases without error' do
tag_name_upper = 'Rails'
tag_name_lower = 'rails'
threads = []
2.times do |i|
threads << Thread.new do
Tag.find_or_create_by_names(i.zero? ? tag_name_upper : tag_name_lower)
end
end
threads.each(&:join)
tags = Tag.where('lower(name) = ?', tag_name_lower.downcase)
expect(tags.count).to eq(1)
expect(tags.first.name.downcase).to eq(tag_name_lower.downcase)
end
end
describe '.search_for' do
it 'finds tag records with matching names' do
tag = Fabricate(:tag, name: 'match')