Do Rails' model hooks wait until database transactions are completed?

Viewed 1121

I have a model with an after_save and after_commit hook. The after_save hook stores some information in the database which are needed by the after_commit hook.

Is it safe to assume that all database transaction are finished before the after_commit hook fires?

Here's a little example to illustrate the problem:

class TicketComment < ApplicationRecord
  after_save :extract_mentions
  after_commit :notify

  def extract_mentions
    current_mentions = mentions.map(&:user).map(&:username)
    new_mentions = description.scan(/(?<=^|(?<=[^a-zA-ZÀ-ž0-9_-]))@([a-zA-ZÀ-ž]+[a-zA-ZÀ-ž0-9_]+)/).flatten

    mentions_to_add = new_mentions - current_mentions
    mentions_to_remove = current_mentions - new_mentions

    users_to_add = User.select(:id).where("username IN (?)", mentions_to_add.flatten).map(&:id)
    users_to_remove = User.select(:id).where("username IN (?)", mentions_to_remove.flatten).map(&:id)

    users_to_add&.each do |id_to_add|
      mentions.create(user_id: id_to_add)
    end

    mentions.where(user_id: users_to_remove).destroy_all
  end

  def notify
    user_ids = ticket.participants.pluck(:id) - [Current.user.id]
    TicketCommentMailer.comment_added(id, user_ids).deliver_later
  end
end

Please mind that this example is simplified. The extract_mentions-part is a model concern which is used across multiple models, so I can't run the notify code inside the extract_mentions-hook.

1 Answers

To expand on my comment with some reference, yes you can assume that all database transactions have finished before your #after_commit callback runs. Here is a comment in active_record/transaction.rb (github):

#after_commit callbacks are called on every record saved or destroyed within a transaction immediately after the transaction is committed. #after_rollback callbacks are called on every record saved or destroyed within a transaction immediately after the transaction or savepoint is rolled back.

These callbacks are useful for interacting with other systems since you will be guaranteed that the callback is only executed when the database is in a permanent state.

The transaction code is a bit hairy, since it handles nested transactions, savepoints, etc. Ultimately, though, in TransactionManager.commit_transaction (active_record/connect_adapters/abstract/transaction.rb), the manager does indeed execute the database-specific commit action before running #after_commit callbacks on all unique record IDs association with a parent transaction.

def commit_transaction
  @connection.lock.synchronize do
    ...
    transaction.commit  # executes database-specific commit action
    transaction.commit_records # executes after_commit callbacks
  end
end

All save and destroy operations, including their persistence callbacks, are implicitly wrapped in a transaction block. Per another comment in the Rails active_record/transaction.rb:

#transaction calls can be nested. By default, this makes all database statements in the nested transaction block become part of the parent transaction.

So, database statements generated by the #create and #destroy_all in your #after_save callback are committed at the same time with the original parent transaction, and can be depended on in the #after_commit callback. A simplified example to illustrate:

class Foo < ApplicationRecord
  after_save :do_stuff
  after_commit :check

  def do_stuff
    Bar.create!(name: 'bar')
  end

  def check
    Rails.logger.info "Bar count: #{Bar.count}"
    Rails.logger.info "name: #{Bar.first.name}"
  end
end

Output log:

> Foo.create!(name: "Jerry")
  (0.0ms)  begin transaction
  Foo Create (0.3ms)  INSERT INTO "foos" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Jerry"], ["created_at", "2020-09-16 01:49:57.480367"], ["updated_at", "2020-09-16 01:49:57.480367"]]
  Bar Create (0.2ms)  INSERT INTO "bars" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "bar"], ["created_at", "2020-09-16 01:49:57.485953"], ["updated_at", "2020-09-16 01:49:57.485953"]]
 (2.3ms)  commit transaction
 (0.1ms)  SELECT COUNT(*) FROM "bars"
Bar count: 1
  Bar Load (0.1ms)  SELECT "bars".* FROM "bars" ORDER BY "bars"."id" ASC LIMIT ?  [["LIMIT", 1]]
bar

Note that of course any #after_commit callbacks will be invoked for any model that is created, updated, or destroyed within the transaction. So, if Bar also defines #after_commit callbacks, those get run too.

Related