vulnerability seccion race ejemplo critica condicion carrera ruby-on-rails database activerecord optimistic-locking

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.