Come posso evitare una condizione di gara nella mia app Rails?

Ho un’applicazione Rails molto semplice che consente agli utenti di registrare la propria presenza su una serie di corsi. I modelli ActiveRecord sono i seguenti:

class Course < ActiveRecord::Base has_many :scheduled_runs ... end class ScheduledRun  :attendances ... end class Attendance  true ... end class User  :attendances, :source => :scheduled_run end 

Un’istanza di ScheduledRun ha un numero finito di posti disponibili e, una volta raggiunto il limite, non è ansible accettare più presenze.

 def full? attendances_count == capacity end 

attendances_count è una colonna della cache del contatore che contiene il numero di associazioni di presenze create per un particolare record ScheduledRun.

Il mio problema è che non conosco appieno il modo corretto per garantire che una condizione di competizione non si verifichi quando 1 o più persone tentano di registrarsi per l’ultimo posto disponibile su un campo allo stesso tempo.

Il mio controller di presenza ha questo aspetto:

 class AttendancesController  :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 

Come puoi vedere, non tiene conto del punto in cui l’istanza ScheduledRun ha già raggiunto la capacità.

Qualsiasi aiuto su questo sarebbe molto apprezzato.

Aggiornare

Non sono sicuro se questo è il modo giusto per eseguire un blocco ottimistico in questo caso, ma ecco cosa ho fatto:

Ho aggiunto due colonne alla tabella ScheduledRuns –

 t.integer :attendances_count, :default => 0 t.integer :lock_version, :default => 0 

Ho anche aggiunto un metodo al modello ScheduledRun:

  def attend(user) attendance = self.attendances.build(:user_id => user.id) attendance.save rescue ActiveRecord::StaleObjectError self.reload! retry unless full? end 

Quando viene salvato il modello di presenze, ActiveRecord procede e aggiorna la colonna della cache del contatore sul modello ScheduledRun. Ecco l’output del registro che mostra dove ciò accade –

 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 si verifica un aggiornamento successivo al modello ScheduledRun prima che venga salvato il nuovo modello di partecipazione, ciò dovrebbe triggersre l’eccezione StaleObjectError. A quel punto, l’intera operazione viene ritentata di nuovo, se la capacità non è già stata raggiunta.

Aggiornamento n. 2

In seguito alla risposta di @ kenn ecco il metodo di aggiornamento aggiornato sull’object 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 

Il blocco ottimistico è la strada da percorrere, ma come forse già notato, il tuo codice non genererà mai ActiveRecord :: StaleObjectError, poiché la creazione dell’object child in has_many association salta il meccanismo di blocco. Dai uno sguardo al seguente SQL:

 UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481) 

Quando aggiorni gli attributi nell’object padre , di solito vedi il seguente SQL:

 UPDATE `scheduled_runs` SET `updated_at` = '2010-07-23 10:44:19', `lock_version` = 2 WHERE id = 113338481 AND `lock_version` = 1 

L’affermazione sopra mostra come è implementato il blocco ottimistico: nota la lock_version = 1 nella clausola WHERE. Quando si verifica una condizione di competizione, i processi concorrenti tentano di eseguire questa query esatta, ma solo la prima riesce, poiché la prima aggiorna atomicamente la versione di blocco a 2 ei processi successivi non riescono a trovare il record e sollevano ActiveRecord :: StaleObjectError, dal momento che lo stesso record non ha lock_version = 1 più.

Quindi, nel tuo caso, una ansible soluzione è quella di toccare il genitore giusto prima di creare / distruggere un object figlio, in questo modo:

 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 

Non è pensato per evitare rigorosamente le condizioni di gara, ma praticamente dovrebbe funzionare nella maggior parte dei casi.

Non devi solo testare se @run.full? ?

 def create unless @user.valid? || @run.full? render :action => 'new' end # ... end 

modificare

Cosa succede se aggiungi una convalida come:

 class Attendance < ActiveRecord::Base validate :validates_scheduled_run def scheduled_run errors.add_to_base("Error message") if self.scheduled_run.full? end end 

Non salverà la @attendance se l'associato scheduled_run è pieno.

Non ho provato questo codice ... ma credo che sia ok.