Skip to content

Commit 62f49fe

Browse files
authored
Add thread-safety and idempotency (#48)
* Add thread-safety and idempotency to loader * Fix CI
1 parent dff84d2 commit 62f49fe

7 files changed

Lines changed: 71 additions & 3 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# frozen_string_literal: true
22

33
gem "activerecord", "~> 5"
4+
gem "concurrent-ruby", "= 1.3.4"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
# frozen_string_literal: true
22

33
gem "activerecord", "~> 6"
4+
gem "benchmark"
5+
gem "bigdecimal"
6+
gem "concurrent-ruby", "= 1.3.4"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
# frozen_string_literal: true
22

33
gem "activerecord", "~> 7"
4+
gem "benchmark"
5+
gem "bigdecimal"
6+
gem "concurrent-ruby", "= 1.3.4"

lib/n1_loader/ar_lazy_preload/loader_patch.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ module LoaderPatch
77
attr_accessor :context_setup
88

99
def loaded
10-
return @loaded if @loaded
10+
return @loaded if @already_loaded && @already_context
1111

1212
super
1313

14-
context_setup&.call(preloaded_records)
14+
synchronize do
15+
context_setup&.call(@loaded.values.flatten) unless @already_context
16+
end
1517

18+
@already_context = true
1619
@loaded
1720
end
1821
end

lib/n1_loader/core/loader.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ module N1Loader
66
# Subclasses must define +perform+ method that accepts single argument
77
# and returns hash where key is the element and value is what we want to load.
88
class Loader
9+
prepend MonitorMixin
10+
911
class << self
1012
attr_reader :arguments
1113

@@ -105,7 +107,17 @@ def fulfill(element, value)
105107
end
106108

107109
def loaded
108-
return @loaded if @loaded
110+
return @loaded if @already_loaded
111+
112+
synchronize do
113+
non_thread_safe_loaded unless @already_loaded
114+
end
115+
116+
@loaded
117+
end
118+
119+
def non_thread_safe_loaded
120+
return @loaded if @already_loaded
109121

110122
check_arguments!
111123

@@ -117,6 +129,7 @@ def loaded
117129
perform(elements)
118130
end
119131

132+
@already_loaded = true
120133
@loaded
121134
end
122135
end

n1_loader.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Gem::Specification.new do |spec|
2121
spec.files = Dir["lib/**/*"]
2222
spec.require_paths = ["lib"]
2323

24+
spec.add_runtime_dependency "mutex_m"
25+
2426
spec.add_development_dependency "activerecord", ">= 5"
2527
spec.add_development_dependency "ar_lazy_preload", ">= 0.6"
2628
spec.add_development_dependency "db-query-matchers", "~> 0.11"

spec/n1_loader_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ def count
3131
elements.each { |element| fulfill(element, [element]) }
3232
end
3333

34+
n1_optimized :sleepy do |elements|
35+
sleep(0.5)
36+
37+
elements.first.class.perform!
38+
elements.each { |element| fulfill(element, [element]) }
39+
end
40+
3441
n1_optimized :custom, custom_loader
3542

3643
n1_optimized :single_optimized do
@@ -138,6 +145,42 @@ def perform(elements)
138145
end.not_to raise_error(NoMethodError)
139146
end
140147

148+
describe "thread-safety" do
149+
it "is thread-safe" do
150+
N1Loader::Preloader.new(objects).preload(:sleepy)
151+
152+
threads = []
153+
154+
10.times do
155+
threads << Thread.new do
156+
objects.each do |obj|
157+
expect(obj.sleepy).to eq([obj])
158+
end
159+
end
160+
end
161+
threads.each(&:join)
162+
163+
expect(klass.count).to eq(1)
164+
end
165+
end
166+
167+
describe "error handling" do
168+
it "raises the same error on the subsequent calls" do
169+
faulty_klass = Class.new do
170+
include N1Loader::Loadable
171+
172+
n1_optimized :faulty do |_|
173+
raise StandardError, "Something went wrong"
174+
end
175+
end
176+
177+
faulty_object = faulty_klass.new
178+
179+
expect { faulty_object.faulty }.to raise_error(StandardError, "Something went wrong")
180+
expect { faulty_object.faulty }.to raise_error(StandardError, "Something went wrong")
181+
end
182+
end
183+
141184
describe "loaded comparison" do
142185
it "compares by value" do
143186
instance = loader.new(objects)

0 commit comments

Comments
 (0)