2010-06-14 14 views
20

Tengo una aplicación de Rails realmente simple que permite a los usuarios registrar su asistencia en un conjunto de cursos. Los modelos ActiveRecord son los siguientes:¿Cómo evito una condición de carrera en mi aplicación Rails?

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 

ejemplo A ScheduledRun tiene un número finito de lugares disponibles, y una vez que se alcanza el límite, no más asistencias puede ser aceptada.

def full? 
    attendances_count == capacity 
end 

attendances_count es una columna de caché contador que tiene el número de asociaciones de asistencia creadas para un registro ScheduledRun particular.

Mi problema es que no conozco completamente la forma correcta de asegurar que una condición de carrera no ocurra cuando 1 o más personas intentan registrarse para el último lugar disponible en un curso al mismo tiempo.

Mi controlador de asistencia es el siguiente:

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 se puede ver, no tiene en cuenta en la instancia ScheduledRun ya ha alcanzado su capacidad máxima.

Cualquier ayuda sobre esto sería muy apreciada.

actualización

No estoy seguro de si este es el camino correcto para llevar a cabo el bloqueo optimista en este caso, pero esto es lo que hice:

añadí dos columnas a la tabla ScheduledRuns -

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

I también se ha añadido un método para modelo ScheduledRun:

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

Cuando se guarda el modelo de Asistencia, ActiveRecord continúa y actualiza la columna de la memoria caché del contador en el modelo ScheduledRun. Aquí está la muestra de salida de registro donde esto sucede -

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) 

Si se produce una actualización posterior al modelo ScheduledRun antes de guardar el nuevo modelo de asistencia, esto debe dar lugar a la excepción StaleObjectError. En ese punto, todo vuelve a intentarse, si todavía no se ha alcanzado la capacidad.

Actualización # 2

raíz de @ respuesta de Kenn aquí está el método asistir actualizada sobre el 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 
+0

Fijo en los últimos rieles. –

+0

Necesita usar un bloqueo optimista. Este screencast le mostrará cómo hacerlo: [link text] (http://railscasts.com/episodes/59-optimistic-locking) – rtacconi

+0

¿Qué quiere decir, dmitry? – Edward

Respuesta

13

El bloqueo optimista es el camino a seguir, pero como ya habrás notado, tu código nunca aumentará ActiveRecord :: StaleObjectError, ya que la creación de objetos secundarios en la asociación has_many omite el mecanismo de bloqueo. Echar un vistazo a la siguiente SQL:

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

Al actualizar atributos en el padre objeto , por lo general, aparece el siguiente código SQL en su lugar:

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

La declaración anterior muestra cómo se implementa el bloqueo optimista : Observe el lock_version = 1 en la cláusula WHERE. Cuando ocurre una condición de carrera, los procesos concurrentes intentan ejecutar esta consulta exacta, pero solo la primera tiene éxito, porque la primera actualiza atómicamente lock_version a 2, y los procesos posteriores fallarán al encontrar el registro y generar ActiveRecord :: StaleObjectError, ya que el mismo registro ya no tiene lock_version = 1.

Así, en su caso, una posible solución es tocar el padre justo antes de crear/destruir un objeto secundario, así:

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 

No es la intención de evitar estrictamente las condiciones de carrera, pero en la práctica debería funcionar en la mayoría de los casos.

+0

Gracias Kenn. No me di cuenta de que la creación de objetos secundarios se saltó el mecanismo de bloqueo. Envolví todo en una transacción también, solo para que el objeto primario no se actualice innecesariamente si la creación del objeto secundario falla. – Cathal

0

¿No sólo hay que probar si @run.full??

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

    # ... 
end 

Editar

Lo que si se agrega una validación como:

class Attendance < ActiveRecord::Base 
    validate :validates_scheduled_run 

    def scheduled_run 
     errors.add_to_base("Error message") if self.scheduled_run.full? 
    end 
end 

No va a salvar el @attendance si el scheduled_run asociado es completa.

No he probado este código ... pero creo que está bien.

+0

Eso no funcionará. El problema es que el registro @run representa puede haber sido actualizado por otra solicitud, dejando @run inconsistente con lo que se representa en la base de datos. Que yo sepa, el bloqueo optimista es la forma de resolver este problema. Sin embargo, ¿cómo se puede aplicar esto a las asociaciones? – Cathal

+0

Derecha ... He editado mi respuesta:] –

Cuestiones relacionadas