Como evito uma condição de corrida no meu aplicativo Rails?
Eu tenho um aplicativo Rails realmente simples que permite aos usuários registrar sua presença em um conjunto de cursos. Os modelos do ActiveRecord são os seguintes:
class Course < ActiveRecord::Base
has_many :scheduled_runs
...
end
class ScheduledRun < ActiveRecord::Base
belongs_to :course
has_many :attendances
has_many :attendees, :through => :attendances
...
end
class Attendance < ActiveRecord::Base
belongs_to :user
belongs_to :scheduled_run, :counter_cache => true
...
end
class User < ActiveRecord::Base
has_many :attendances
has_many :registered_courses, :through => :attendances, :source => :scheduled_run
end
Uma instância ScheduledRun tem um número finito de vagas disponíveis e, uma vez atingido o limite, não há mais presença de presenças.
def full?
attendances_count == capacity
end
frequentances_count é uma coluna de contador de cache que contém o número de associações de atendimento criadas para um registro ScheduledRun específico.
Meu problema é que não conheço completamente a maneira correta de garantir que uma condição de corrida não ocorra quando 1 ou mais pessoas tentam se registrar no último lugar disponível em um percurso ao mesmo tempo.
Meu controlador de presença fica assim:
class AttendancesController < ApplicationController
before_filter :load_scheduled_run
before_filter :load_user, :only => :create
def new
@user = User.new
end
def create
unless @user.valid?
render :action => 'new'
end
@attendance = @user.attendances.build(:scheduled_run_id => params[:scheduled_run_id])
if @attendance.save
flash[:notice] = "Successfully created attendance."
redirect_to root_url
else
render :action => 'new'
end
end
protected
def load_scheduled_run
@run = ScheduledRun.find(params[:scheduled_run_id])
end
def load_user
@user = User.create_new_or_load_existing(params[:user])
end
end
Como você pode ver, não leva em consideração onde a instância ScheduledRun já atingiu a capacidade.
Qualquer ajuda neste assunto seria altamente apreciada.
Atualizar
Não tenho certeza se este é o caminho certo para executar o bloqueio otimista neste caso, mas aqui está o que eu fiz:
Adicionei duas colunas à tabela ScheduledRuns -
t.integer :attendances_count, :default => 0
t.integer :lock_version, :default => 0
Também adicionei um método ao modelo ScheduledRun:
def attend(user)
attendance = self.attendances.build(:user_id => user.id)
attendance.save
rescue ActiveRecord::StaleObjectError
self.reload!
retry unless full?
end
Quando o modelo de atendimento é salvo, o ActiveRecord segue em frente e atualiza a coluna de cache do contador no modelo ScheduledRun. Aqui está a saída do log mostrando onde isso acontece -
ScheduledRun Load (0.2ms) SELECT * FROM `scheduled_runs` WHERE (`scheduled_runs`.`id` = 113338481) ORDER BY date DESC
Attendance Create (0.2ms) INSERT INTO `attendances` (`created_at`, `scheduled_run_id`, `updated_at`, `user_id`) VALUES('2010-06-15 10:16:43', 113338481, '2010-06-15 10:16:43', 350162832)
ScheduledRun Update (0.2ms) UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481)
Se uma atualização subsequente ocorrer no modelo ScheduledRun antes que o novo modelo de Atendimento seja salvo, isso deve acionar a exceção StaleObjectError. Nesse ponto, a coisa toda é repetida novamente, se a capacidade ainda não tiver sido atingida.
Atualização # 2
A seguir à resposta de @ kenn, aqui está o método de participação atualizado no objeto SheduledRun:
# creates a new attendee on a course
def attend(user)
ScheduledRun.transaction do
begin
attendance = self.attendances.build(:user_id => user.id)
self.touch # force parent object to update its lock version
attendance.save # as child object creation in hm association skips locking mechanism
rescue ActiveRecord::StaleObjectError
self.reload!
retry unless full?
end
end
end