Как мне избежать состояния гонки в приложении Rails?
У меня действительно простое приложение Rails, которое позволяет пользователям регистрировать свое участие в наборе курсов. Модели ActiveRecord следующие:
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
Экземпляр ScheduledRun имеет конечное количество доступных мест, и как только предел достигнут, больше посетителей не может быть принято.
def full?
attendances_count == capacity
end
attendances_count - столбец кеша счетчика, содержащий количество ассоциаций посещаемости, созданных для определенной записи ScheduledRun.
Моя проблема заключается в том, что я не полностью знаю правильный способ убедиться, что условие гонки не происходит, когда 1 или более человек пытаются зарегистрироваться в течение последнего доступного места на курсе в то же время.
Контроллер My Attendance выглядит следующим образом:
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
Как вы можете видеть, он не учитывает, когда экземпляр ScheduledRun уже достиг мощности.
Любая помощь по этому поводу будет принята с благодарностью.
Обновление
Я не уверен, что это правильный способ выполнить оптимистическую блокировку в этом случае, но вот что я сделал:
Я добавил два столбца в таблицу ScheduledRuns -
t.integer :attendances_count, :default => 0
t.integer :lock_version, :default => 0
Я также добавил метод ScheduledRun:
def attend(user)
attendance = self.attendances.build(:user_id => user.id)
attendance.save
rescue ActiveRecord::StaleObjectError
self.reload!
retry unless full?
end
Когда модель посещаемости сохраняется, ActiveRecord идет вперед и обновляет столбец кеша счетчика в модели ScheduledRun. Здесь вывод журнала, где это происходит -
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)
Если последующее обновление происходит с моделью ScheduledRun до сохранения новой модели посещаемости, это должно вызвать исключение StaleObjectError. В этот момент все это снова повторяется, если емкость еще не достигнута.
Обновление # 2
Вслед за ответом @kenn здесь приведен обновленный метод посещения объекта 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
Ответы
Ответ 1
Оптимистическая блокировка - это путь, но, как вы уже заметили, ваш код никогда не будет поднимать ActiveRecord:: StaleObjectError, поскольку создание дочерних объектов в has_many ассоциации пропускает механизм блокировки. Взгляните на следующий SQL:
UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481)
Когда вы обновляете атрибуты в родительском объекте, вы обычно видите следующий SQL:
UPDATE `scheduled_runs` SET `updated_at` = '2010-07-23 10:44:19', `lock_version` = 2 WHERE id = 113338481 AND `lock_version` = 1
В приведенном выше заявлении показано, как реализована оптимистическая блокировка: обратите внимание на предложение lock_version = 1
в WHERE. Когда состояние гонки происходит, одновременные процессы пытаются запустить этот точный запрос, но только первый успешно, потому что первый из них атомически обновляет lock_version до 2, а последующие процессы не смогут найти запись и поднять ActiveRecord:: StaleObjectError, поскольку у той же записи больше нет lock_version = 1
.
Таким образом, в вашем случае возможным обходным путем является касание родительского права до того, как вы создадите/уничтожите дочерний объект, например:
def attend(user)
self.touch # Assuming you have updated_at column
attendance = self.attendances.create(:user_id => user.id)
rescue ActiveRecord::StaleObjectError
#...do something...
end
Это не означает строго избегать условий гонки, но практически в большинстве случаев это должно работать.
Ответ 2
Вам просто не нужно проверять, есть ли @run.full?
?
def create
unless @user.valid? || @run.full?
render :action => 'new'
end
# ...
end
Edit
Что делать, если вы добавили проверку, например:
class Attendance < ActiveRecord::Base
validate :validates_scheduled_run
def scheduled_run
errors.add_to_base("Error message") if self.scheduled_run.full?
end
end
Он не сохранит @attendance
, если связанный scheduled_run
заполнен.
Я не тестировал этот код... но я считаю, что это нормально.