muxcmux
muxcmux

Reputation: 237

Ruby with_advisory_lock test with multiple threads fails intermittently

I'm using the with_advisory_lock gem to try and ensure that a record is created only once. Here's the github url to the gem.

I have the following code, which sits in an operation class that I wrote to handle creating user subscriptions:

def create_subscription_for user
  subscription = UserSubscription.with_advisory_lock("lock_%d" % user.id) do
    UserSubscription.where({ user_id: user.id }).first_or_create
  end

  # do more stuff on that subscription
end

and the accompanying test:

threads = []
user = FactoryBot.create(:user)

rand(5..10).times do
  threads << Thread.new do
    subject.create_subscription_for(user)
  end
end

threads.each(&:join)

expect(UserSubscription.count).to eq(1)

What I expect to happen:

What actually happens:

What is more weird is that if I add a sleep for a few hundred milliseconds in my method just before the find_or_create method, the test never fails:

def create_subscription_for user
  subscription = UserSubscription.with_advisory_lock("lock_%d" % user.id) do
    sleep 0.2
    UserSubscription.where({ user_id: user.id }).first_or_create
  end

  # do more stuff on that subscription
end

My questions are: "Why is adding the sleep 0.2 making the tests always pass?" and "Where do I look to debug this?"

Thanks!

UPDATE: Tweaking the tests a little bit causes them to always fail:

threads = []
user = FactoryBot.create(:user)

rand(5..10).times do
  threads << Thread.new do
    sleep
    subject.create_subscription_for(user)
  end
end

until threads.all? { |t| t.status == 'sleep' }
  sleep 0.1
end

threads.each(&:wakeup)
threads.each(&:join)

expect(UserSubscription.count).to eq(1)

I have also wrapped first_or_create in a transaction, which makes the test pass and everything to work as expected:

def create_subscription_for user
  subscription = UserSubscription.with_advisory_lock("lock_%d" % user.id) do
    UserSubscription.transaction do
      UserSubscription.where({ user_id: user.id }).first_or_create
    end
  end

  # do more stuff on that subscription
end

So why is wrapping first_or_create in a transaction necessary to make things work?

Upvotes: 3

Views: 1904

Answers (1)

user1032752
user1032752

Reputation: 881

Are you turning off transactional tests for this test case? I'm working on something similar and that proved to be important to actually simulating the concurrency.

See uses_transaction https://api.rubyonrails.org/classes/ActiveRecord/TestFixtures/ClassMethods.html

If transactions are not turned off, Rails will wrap the entire test in a transaction and this will cause all the threads to share one DB connection. Furthermore, in Postgres a session-level advisory lock can always be re-acquired within the same session. From the docs:

If a session already holds a given advisory lock, additional requests by it will always succeed, even if other sessions are awaiting the lock; this statement is true regardless of whether the existing lock hold and new request are at session level or transaction level.

Based on that I'm suspecting that your lock is always able to be acquired and therefore the .first_or_create call is always executed which results in the intermittent RecordNotUnique exceptions.

Upvotes: 4

Related Questions