Skip to content

Instantly share code, notes, and snippets.

@JoSuzuki
Created July 4, 2023 22:12
Show Gist options
  • Save JoSuzuki/a51ffcd5131780a5a03523239bd005f1 to your computer and use it in GitHub Desktop.
Save JoSuzuki/a51ffcd5131780a5a03523239bd005f1 to your computer and use it in GitHub Desktop.
You won't believe this bug! Active record association save freeze error

Chapter 0: Setup

Recently I came across a bug that took me a day to understand what was happening. Below is a sample reproduction of the relevant files I was working with:

app/models/user.rb

class User < ApplicationRecord
  # columns id, email, name
  has_many :comments, dependent: :destroy
end

app/models/comment.rb

class Comment < ApplicationRecord
  # columns id, text, user_id
  belongs_to :user

  after_validation :destroy_comment_if_empty
  validates :user, presence: true

  def destroy_comment_if_empty
    destroy if text.blank?
  end
end

app/services/upsert_user_service.rb

class UpsertUserService
  include Virtus.model # it just allows us to declare instance attributes in a more handy way
  
  attribute :comments_params
  attribute :user_params
  
  def call
    User.transaction do
      upsert_user
    end
  end
  
  def upsert_user
    user = User.find_or_initialize_by(email: user_params["email"])
    user.assign_attributes(name: user_params["name"])
    
    user.comments << comments
    
    user.save!
  end
  
  def comments
    comments_params["comments"].map { |comment_params| create_comment(comment_params) }
  end
  
  def create_comment(comment_params)
    comment = user.comments.find_or_initialize_by(id: comment_params["id"])
    comment.text = comment_params["text"])
    
    comment
  end
end

Upsert means that if the record exists we update the record, if it doesn't we create the record.

The bug was happening at user.comments << comments in app/services/upsert_user_service.rb, in the method upsert_user:

class UpsertUserService
  def upsert_user
    user = User.find_or_initialize_by(email: user_params["email"])
    user.assign_attributes(name: user_params["name"])
    
    user.comments << comments # <--- FrozenError: can't modify frozen attributes
    
    user.save!
  end
end

After debugging a little bit I got to a few conclusions:

  • The bug only happened if the call to the service included a comment with a blank text.
  • The bug only happened in the second call of the service (or in more accurate words, if the user already existed)

See if you can guess what is happening and then come back, because we are in for a ride.

Chapter 1: The error

To understand why it happened only in the second request we need to go back to the rails documentation about associations:

Adding an object to a collection (has_many or has_and_belongs_to_many) automatically saves that object, except if the parent object (the owner of the collection) is not yet stored in the database.

(source)

So when we did this the first time, user did not exist, which meant that this was only an assignment, no db access or whatsoever. It would be saved in the line below user.save!.

During the second request, user would already exist, so this line would trigger an automatic save. During this save one very interesting thing would happen: we have one callback after_validation that destroys the comment if it is blank.

So during the validation it would destroy the record (notice we start a destroy flow in the middle of a validation) and later when we attempted a save, this save with the record destroyed would throw the error: FrozenError: can't modify frozen attributes because destroying an object freezes it.

Mystery solved right? Closed case... Well mostly, for a fix to be developed this is enough information, but there were still way too many weird things that didn't make sense:

  1. We had a test that covered removing an existing comment of an existing user, why did that test pass?
  2. If saving a comment with text: "" failed, how did the first request with user.save! worked?

Chapter 2: The working test

So... we had a test to cover this case... or at least we thought. In the test case, we had an user and we had a comment with text and we updated the text of that comment to blank. And it passed! What? How is creating an empty comment different than updating one to be empty?

If you try to run the following snippet in isolation in the console you will see that it triggers a rollback

# setting up example
u = User.find(1)
u.comments = []
c1 = u.comments.create(text: "first")
c2 = u.comments.create(text: "second")

# executing
u = u.reload
c1 = u.comments.find_or_initialize_by(id: c1.id)
c1.text = ""
c2 = u.comments.find_or_initialize_by(id: c2.id)
c2.text = "SECOND"
u.comments << [c1, c2] # TRANSACTION (5.7ms)  ROLLBACK

This last line is exactly what we are executing at user.comments << comments, so why doesn't the test break? Well it turns out that this concat operation opens a transaction as we can see here

https://github.com/rails/rails/blob/f09dc7c4c2e8b9375345d443c230cb8d78ad6a18/activerecord/lib/active_record/associations/collection_association.rb#L121

image

And what happens with nested transactions? 💩 happens, basically a rollback of a nested transaction is ignored and is commited, you can find this documented here

image (source)

One thing to note is: even if it wasn't a nested transaction this block would continue, the difference is that the rollback would've happened.

So the execution continues and if we check user.comments we will see that even though the save did not work, the attribution did! So saving it once again with user.save! works and succesfully destroys the comment with no text and makes our test pass.

Oh and why did it rollback during the update, but not during create? I believe it is because when we are creating we try to update id = newly_generated_id and this throws the Frozen error and when updating it makes a query for a record that doesn't exist triggering a rollback, but I didn't go after this, perhaps this could be for you reading? 😎

Chapter 3: Association save

Well all that is nice and all, but one thing that bugged me the entire time was: how did calling save in a comment with no value did not work, but calling user.save! with the change in the comment work?

In order to understand that we need to once again do a deep dive in the rails codebase, it turns out that when rails is autosaving an association it doesn't do everything at once: it first validates it and then saves it, in two different operations. association.validate then association.save(validate: false).

https://github.com/rails/rails/blob/f09dc7c4c2e8b9375345d443c230cb8d78ad6a18/activerecord/lib/active_record/autosave_association.rb#L416

image

Because of this quirk, we destroy the comment during the validation cycle and when we get to the line in the snippet above next if record.destroyed?, the record is already destroyed so it is skipped and not saved (which would break if we did just comment.save directly on a comment with blank text)

Chapter 4: The solution

This brings us to the aftermath of everything, the solution isn't that interesting, we preemptively destroyed records with no value.

  def comments
    comments_params["comments"].filter_map do |comment_params|
      comment = create_comment(comment_params)
      next if commment.blank? && comment.destroy!
      comment
    end
  end

Although there were other solutions on the table that could be better:

autosave: true

Because we are building comments with user.comments.find_or_initialize_by(id: comment_params["id"]), we could stop attributing user.comments << comments and just save user.save! directly, but this has a problem documented here, where association attributes changed don't save properly. Unless we set has_many :comments, dependent: :destroy, autosave: true in user.rb, but this change could be risky as it affects all places where user is saved with comments.

find_inplace_or_initialize

We could create a association method and use it instead of doing an autosave.

class User < ApplicationRecord
  has_many :comments, dependent: :destroy do
    def find_inplace_or_initialize_by_id(id)
      find do |comment|
        comment.id == id
      end || new()
    end
  end
end

And using this method instead of user.comments.find_or_initialize_by, just beware that the method above is considerable slower if there are many comments.

PS.: One thing the documentation doesn't say is that:

post = Post.create(title: 'ruby rocks')
comment = post.comments.create(body: 'hello world')
comment.body = 'hi everyone'
post.save # => saves post, but not comment

Doesn't save the comment, but

post = Post.create(title: 'ruby rocks')
post.comments.create(body: 'hello world')
comment = post.comments.first
comment.body = 'hi everyone'
post.save # => saves post and comment

Does save the post and comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment