diff --git a/lib/service/base.rb b/lib/service/base.rb index 0721e934adb..62d2cde87bc 100644 --- a/lib/service/base.rb +++ b/lib/service/base.rb @@ -129,16 +129,16 @@ module Service # @!visibility private class Step - attr_reader :name, :method_name, :class_name, :instance, :context + attr_reader :name, :method_name, :class_name def initialize(name, method_name = name, class_name: nil) - @name = name - @method_name = method_name - @class_name = class_name + @name, @method_name, @class_name = name, method_name, class_name + @instance = Concurrent::ThreadLocalVar.new + @context = Concurrent::ThreadLocalVar.new end def call(instance, context) - @instance, @context = instance, context + @instance.value, @context.value = instance, context context[result_key] = Context.build with_runtime { run_step } end @@ -147,6 +147,10 @@ module Service "result.#{type}.#{name}" end + def instance = @instance.value + + def context = @context.value + private def run_step @@ -252,6 +256,7 @@ module Service attr_reader :steps def initialize(&block) + super("") @steps = [] instance_exec(&block) end @@ -268,8 +273,8 @@ module Service attr_reader :steps, :keys def initialize(*keys, &block) + super(keys.join(":")) @keys = keys - @name = keys.join(":") @steps = [] instance_exec(&block) end @@ -308,7 +313,7 @@ module Service attr_reader :steps, :exceptions def initialize(exceptions, &block) - @name = "default" + super("default") @steps = [] @exceptions = exceptions.presence || [StandardError] instance_exec(&block) diff --git a/spec/lib/service_spec.rb b/spec/lib/service_spec.rb index 37c1ee5d176..df647a8e4cd 100644 --- a/spec/lib/service_spec.rb +++ b/spec/lib/service_spec.rb @@ -122,4 +122,31 @@ RSpec.describe Service do end end end + + describe "Thread safety" do + context "when services are run concurrently" do + let(:service) do + Class.new do + include Service::Base + + policy :limit_reached + step :update + + def limit_reached + $redis.get("my_counter").to_i < 3 + end + + def update + $redis.incr("my_counter") + end + end + end + + it "works properly" do + # When not thread-safe, a failed service can hold the wrong context, + # which raises an exception + expect { 10.times.map { Thread.new { service.call } }.map(&:join) }.not_to raise_error + end + end + end end