I've got a rails app running ruby 2.4.4 using Unicorn as a web server which makes use of a singleton to read from Kafka in a background thread. The idea is to have a single instance of the singleton per unicorn process. So 4 processes, 4 singletons.
I kick off the kafka consumption inside the after_fork
hook in my unicorn config. I can successfully wait for the consumption of historic messages to complete (verified by putting a pry immediately after).
However when I get to the point of serving traffic, the singleton instance is a) a different instance, and b) empty - the ivar previously set is gone.
I have confirmed that I am inside the same process and the same thread.
The setup is as follows:
# background_foo_consumer.rb
class BackgroundFooConsumer
include Singleton
attr_reader :background_consumer
def add_background_consumer(consumer, topics, options: nil)
@background_consumer ||= BackgroundKafkaConsumer.new(consumer, topics, options: options)
end
def processed_historical_messages?
background_consumer&.consumer&.reached_head
end
end
# config/unicorn.rb
after_worker_ready do |server, worker|
BackgroundFooConsumer.instance.add_background_consumer(nil, ["foos"])
BackgroundFooConsumer.instance.background_consumer.start
BackgroundFooConsumer.instance.background_consumer.consumer.mutex.synchronize {
BackgroundFooConsumer
.instance.background_consumer.consumer.processed_historical_messages.wait(
BackgroundFooConsumer.instance.background_consumer.consumer.mutex
)
}
end
end
I confirmed I am inside the same process, even the same thread, as I can successfully pass the correct object through to the application by replacing include Singleton
with a custom implementation and Thread local variables as follows:
# config/unicorn.rb
after_worker_ready do |server, worker|
# ... same as above
Thread.current[:background_foo_consumer] = BackgroundFooConsumer.instance
end
# background_foo_consumer.rb
class BackgroundFooConsumer
attr_reader :background_consumer
def self.instance
@instance ||= begin
Thread.current[:background_foo_consumer] || self.new
ensure
Thread.current[:background_foo_consumer] = nil
end
end
end
In this implementation, when I come to serve traffic from my app BackgroundFooConsumer.instance
is the correct instance created in the after_fork
hook, and there is an independent instance per unicorn process, confirmed by checking the object id.
I don't believe this is the GC, at least the underlying object does not get mopped up, I have confirmed this by setting the Thread local variable in the after_fork hook, but then using include Singleton
in my consumer class. I still get the empty/new singleton, but the thread local variable is still present if I query it directly.
My current hypothesis is that this is something to do with copy on write, and by setting the thread local variables I somehow force ruby to create me a singleton for that process only and save it to that variable.
So my question is how can a singleton instance disappear like this inside a single thread? And how can I stop it from happening? I'd prefer not to use these thread local variables if I can help it.