ruby on rails - seccion - ¿Cómo evito una condición de carrera en mi aplicación Rails?
race condition vulnerability (2)
Tengo una aplicación de Rails realmente simple que permite a los usuarios registrar su asistencia en un conjunto de cursos. Los modelos de ActiveRecord son los siguientes:
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
Una instancia de ScheduledRun tiene un número finito de lugares disponibles, y una vez que se alcanza el límite, no se pueden aceptar más asistencias.
def full?
attendances_count == capacity
end
attendances_count es una columna de contador de caché que contiene el número de asociaciones de asistencia creadas para un registro de Run Scheduled en particular.
Mi problema es que no conozco completamente la forma correcta de garantizar que no se produzca una condición de carrera cuando 1 o más personas intentan registrarse para el último lugar disponible en un curso al mismo tiempo.
Mi controlador de asistencia se ve así:
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 puede ver, no tiene en cuenta dónde la instancia de ScheduledRun ya ha alcanzado su capacidad.
Cualquier ayuda sobre esto sería muy apreciada.
Actualizar
No estoy seguro de si esta es la forma correcta de realizar un bloqueo optimista en este caso, pero esto es lo que hice:
Agregué dos columnas a la tabla de reglas programadas -
t.integer :attendances_count, :default => 0
t.integer :lock_version, :default => 0
También agregué un método al 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 del caché del contador en el modelo de ejecución programada. Aquí está la salida de registro que muestra dónde sucede esto:
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 del modelo de ejecución programada antes de que se guarde el nuevo modelo de asistencia, esto debería desencadenar la excepción StaleObjectError. En ese punto, todo vuelve a intentarse, si todavía no se ha alcanzado la capacidad.
Actualización # 2
A continuación de la respuesta de @Kenn aquí está el método actualizado de asistencia en 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
¿No tienes que probar si @run.full?
?
def create
unless @user.valid? || @run.full?
render :action => ''new''
end
# ...
end
Editar
¿Qué sucede si 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 guardará @attendance
si el scheduled_run
asociado está lleno.
No he probado este código ... pero creo que está bien.
El bloqueo optimista es el camino a seguir, pero como ya habrá notado, su código nunca elevará ActiveRecord :: StaleObjectError, ya que la creación de objetos secundarios en la asociación has_many omite el mecanismo de bloqueo. Eche un vistazo al siguiente SQL:
UPDATE `scheduled_runs` SET `lock_version` = COALESCE(`lock_version`, 0) + 1, `attendances_count` = COALESCE(`attendances_count`, 0) + 1 WHERE (`id` = 113338481)
Cuando actualiza atributos en el objeto principal , generalmente ve el siguiente SQL:
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 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 no podrán encontrar el registro y generar ActiveRecord :: StaleObjectError, ya que el el mismo registro no tiene lock_version = 1
más tiempo.
Por lo tanto, en su caso, una posible solución alternativa es tocar el elemento primario antes de crear / destruir un objeto secundario, de esta manera:
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 pretende evitar estrictamente las condiciones de carrera, pero prácticamente debería funcionar en la mayoría de los casos.