I've got a rather easy Rails application that enables customers to join up their attendance on some courses. The ActiveRecord models are the following:
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
A ScheduledRun instance includes a finite quantity of places available, and when the limit is arrived at, forget about attendances could be recognized.
def full? attendances_count == capacity end
attendances_count is really a counter cache column holding the amount of attendance associations produced for the ScheduledRun record.
My issue is which i don't fully know the right way to make sure that a race condition does not occur when 1 or even more people make an effort to register going back available put on a training course simultaneously.
My Attendance controller appears like this:
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
As you can tell, it does not consider in which the ScheduledRun instance has arrived at capacity.
Any help on this is greatly appreciated.
I am not sure if this sounds like the proper way to perform positive securing within this situation, but some tips about what Used to do:
I added two posts towards the ScheduledRuns table -
t.integer :attendances_count, :default => 0 t.integer :lock_version, :default => 0
I additionally added a means to ScheduledRun model:
def attend(user) attendance = self.attendances.build(:user_id => user.id) attendance.save rescue ActiveRecord::StaleObjectError self.reload! retry unless full? end
Once the Attendance model is saved, ActiveRecord goes ahead and updates the counter cache column around the ScheduledRun model. Here's the log output showing where this occurs -
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)
If your subsequent update happens towards the ScheduledRun model prior to the new Attendance model is saved, this will trigger the StaleObjectError exception. After which, the entire factor is retried again, if capacity has not recently been arrived at.
Following on from @kenn's response this is actually the up-to-date attend method around the SheduledRun object:
# 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