Rails 2.1 - Partial Updates May Create Invalid Records

Rails 2.1 will do partial SQL updates. This will improve performance, but it has the potential to create invalid records. Two update queries can each change an attribute that in combination makes a record invalid. Prior to Rails 2.1, the second update would change all the attributes, preventing this issue.

Here's an arbitrary example. Let's say we have an Employee class. Employees must have a salary of 1, 2, or 3. Managers must have a salary of 3, 4, or 5.

Here's the schema and the Employee class.

create_table :employees do |t|
  t.boolean :manager
  t.integer :salary
end

class Employee < ActiveRecord::Base
  validate :validate_salary
  
  protected
  
  def validate_salary
    valid_salary_range = manager? ? (3..6) : (1..3)
    unless valid_salary_range.include?(salary)
      errors.add_to_base("Salary is not valid.")
    end
  end
end

Let's create an employee who is not a manager and has a salary of 3.

employee = Employee.create!(:manager => false, :salary => 3)

Web requests come in to separate processes to update the employee. Process 1 and process 2 each fetch the record before either process does an update.

update1 = Employee.find(employee.id)
update2 = Employee.find(employee.id)

The first process makes the employee a manager. The salary stays at 3, which is valid for a manager.

update1.manager = true
update1.save!
#=> true

The second process lowers the employee's salary to 2, which is valid for a non-manager.

update2.salary = 2
update2.save!
#=> true

The two updates in conjunction make the record invalid.

Employee.find(employee.id).valid?
#=> false

Here is the SQL from the updates.

UPDATE `employees` SET `manager` = 1 WHERE `id` = 1
UPDATE `employees` SET `salary` = 2 WHERE `id` = 1

Prior to Rails 2.1, the SQL for the updates would have been:

UPDATE `employees` SET `salary` = 3, `manager` = 1 WHERE `id` = 1
UPDATE `employees` SET `salary` = 2, `manager` = 0 WHERE `id` = 1

This is only going to happen if two updates occur close to the same time, so it's more likely on high traffic sites. It also will only happen if a validation depends on the state of two or more attributes.

The Solution

To prevent this problem, you can either use locking, or set partial_updates to false for the model. In this example, it would be Employee.partial_updates = false.