Skip to content

Commit

Permalink
Add after_commit and after_rollback callbacks to ActiveRecord that ar…
Browse files Browse the repository at this point in the history
…e called after transactions either commit or rollback on all records saved or destroyed in the transaction.

[#2991 state:committed]

Signed-off-by: Jeremy Kemper <jeremy@bitsweat.net>
  • Loading branch information
Brian Durand authored and jeremy committed Apr 29, 2010
1 parent 20f0e9f commit da840d1
Show file tree
Hide file tree
Showing 5 changed files with 466 additions and 28 deletions.
2 changes: 2 additions & 0 deletions activerecord/CHANGELOG
@@ -1,5 +1,7 @@
*Rails 3.0.0 [beta 4/release candidate] (unreleased)*

* New callbacks: after_commit and after_rollback. Do expensive operations like image thumbnailing after_commit instead of after_save. #2991 [Brian Durand]

* Serialized attributes are not converted to YAML if they are any of the formats that can be serialized to XML (like Hash, Array and Strings). [José Valim]

* Destroy uses optimistic locking. If lock_version on the record you're destroying doesn't match lock_version in the database, a StaleObjectError is raised. #1966 [Curtis Hawthorne]
Expand Down
Expand Up @@ -122,6 +122,8 @@ def transaction(options = {})
requires_new = options[:requires_new] || !last_transaction_joinable

transaction_open = false
@_current_transaction_records ||= []

begin
if block_given?
if requires_new || open_transactions == 0
Expand All @@ -132,6 +134,7 @@ def transaction(options = {})
end
increment_open_transactions
transaction_open = true
@_current_transaction_records.push([])
end
yield
end
Expand All @@ -141,8 +144,10 @@ def transaction(options = {})
decrement_open_transactions
if open_transactions == 0
rollback_db_transaction
rollback_transaction_records(true)
else
rollback_to_savepoint
rollback_transaction_records(false)
end
end
raise unless database_transaction_rollback.is_a?(ActiveRecord::Rollback)
Expand All @@ -157,20 +162,35 @@ def transaction(options = {})
begin
if open_transactions == 0
commit_db_transaction
commit_transaction_records
else
release_savepoint
save_point_records = @_current_transaction_records.pop
unless save_point_records.blank?
@_current_transaction_records.push([]) if @_current_transaction_records.empty?
@_current_transaction_records.last.concat(save_point_records)
end
end
rescue Exception => database_transaction_rollback
if open_transactions == 0
rollback_db_transaction
rollback_transaction_records(true)
else
rollback_to_savepoint
rollback_transaction_records(false)
end
raise
end
end
end

# Register a record with the current transaction so that its after_commit and after_rollback callbacks
# can be called.
def add_transaction_record(record)
last_batch = @_current_transaction_records.last
last_batch << record if last_batch
end

# Begins the transaction (and turns off auto-committing).
def begin_db_transaction() end

Expand Down Expand Up @@ -268,6 +288,42 @@ def sanitize_limit(limit)
limit.to_i
end
end

# Send a rollback message to all records after they have been rolled back. If rollback
# is false, only rollback records since the last save point.
def rollback_transaction_records(rollback) #:nodoc
if rollback
records = @_current_transaction_records.flatten
@_current_transaction_records.clear
else
records = @_current_transaction_records.pop
end

unless records.blank?
records.uniq.each do |record|
begin
record.rolledback!(rollback)
rescue Exception => e
record.logger.error(e) if record.respond_to?(:logger)
end
end
end
end

# Send a commit message to all records after they have been committed.
def commit_transaction_records #:nodoc
records = @_current_transaction_records.flatten
@_current_transaction_records.clear
unless records.blank?
records.uniq.each do |record|
begin
record.committed!
rescue Exception => e
record.logger.error(e) if record.respond_to?(:logger)
end
end
end
end
end
end
end
131 changes: 117 additions & 14 deletions activerecord/lib/active_record/transactions.rb
Expand Up @@ -12,6 +12,9 @@ class TransactionError < ActiveRecordError # :nodoc:
[:destroy, :save, :save!].each do |method|
alias_method_chain method, :transactions
end

define_model_callbacks :commit, :commit_on_update, :commit_on_create, :commit_on_destroy, :only => :after
define_model_callbacks :rollback, :rollback_on_update, :rollback_on_create, :rollback_on_destroy
end

# Transactions are protective blocks where SQL statements are only permanent
Expand Down Expand Up @@ -108,7 +111,7 @@ class TransactionError < ActiveRecordError # :nodoc:
# rescue ActiveRecord::StatementInvalid
# # ...which we ignore.
# end
#
#
# # On PostgreSQL, the transaction is now unusable. The following
# # statement will cause a PostgreSQL error, even though the unique
# # constraint is no longer violated:
Expand All @@ -132,7 +135,7 @@ class TransactionError < ActiveRecordError # :nodoc:
# raise ActiveRecord::Rollback
# end
# end
#
#
# User.find(:all) # => empty
#
# It is also possible to requires a sub-transaction by passing
Expand All @@ -147,7 +150,7 @@ class TransactionError < ActiveRecordError # :nodoc:
# raise ActiveRecord::Rollback
# end
# end
#
#
# User.find(:all) # => Returns only Kotori
#
# Most databases don't support true nested transactions. At the time of
Expand All @@ -157,6 +160,26 @@ class TransactionError < ActiveRecordError # :nodoc:
# http://dev.mysql.com/doc/refman/5.0/en/savepoints.html
# for more information about savepoints.
#
# === Callbacks
#
# There are two types of callbacks associated with committing and rolling back transactions:
# after_commit and after_rollback.
#
# The after_commit callbacks are called on every record saved or destroyed within a
# transaction immediately after the transaction is committed. The after_rollback callbacks
# are called on every record saved or destroyed within a transaction immediately after the
# transaction or savepoint is rolled back.
#
# Additionally, there are callbacks for after_commit_on_create, after_rollback_on_create,
# after_commit_on_update, after_rollback_on_update, after_commit_on_destroy, and
# after_rollback_on_destroy which are only called if a record is created, updated or destroyed
# in the transaction.
#
# 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. For example,
# after_commit is a good spot to put in a hook to clearing a cache since clearing it from
# within a transaction could trigger the cache to be regenerated before the database is updated.
#
# === Caveats
#
# If you're on MySQL, then do not use DDL operations in nested transactions
Expand All @@ -166,7 +189,7 @@ class TransactionError < ActiveRecordError # :nodoc:
# is finished and tries to release the savepoint it created earlier, a
# database error will occur because the savepoint has already been
# automatically released. The following example demonstrates the problem:
#
#
# Model.connection.transaction do # BEGIN
# Model.connection.transaction(:requires_new => true) do # CREATE SAVEPOINT active_record_1
# Model.connection.create_table(...) # active_record_1 now automatically released
Expand Down Expand Up @@ -197,24 +220,55 @@ def save_with_transactions(*args) #:nodoc:
end

def save_with_transactions! #:nodoc:
rollback_active_record_state! { self.class.transaction { save_without_transactions! } }
with_transaction_returning_status(:save_without_transactions!)
end

# Reset id and @new_record if the transaction rolls back.
def rollback_active_record_state!
id_present = has_attribute?(self.class.primary_key)
previous_id = id
previous_new_record = new_record?
remember_transaction_record_state
yield
rescue Exception
@new_record = previous_new_record
if id_present
self.id = previous_id
restore_transaction_record_state
raise
ensure
clear_transaction_record_state
end

# Call the after_commit callbacks
def committed! #:nodoc:
if transaction_record_state(:new_record)
_run_commit_on_create_callbacks
elsif transaction_record_state(:destroyed)
_run_commit_on_destroy_callbacks
else
@attributes.delete(self.class.primary_key)
@attributes_cache.delete(self.class.primary_key)
_run_commit_on_update_callbacks
end
_run_commit_callbacks
ensure
clear_transaction_record_state
end

# Call the after rollback callbacks. The restore_state argument indicates if the record
# state should be rolled back to the beginning or just to the last savepoint.
def rolledback!(force_restore_state = false) #:nodoc:
if transaction_record_state(:new_record)
_run_rollback_on_create_callbacks
elsif transaction_record_state(:destroyed)
_run_rollback_on_destroy_callbacks
else
_run_rollback_on_update_callbacks
end
_run_rollback_callbacks
ensure
restore_transaction_record_state(force_restore_state)
end

# Add the record to the current transaction so that the :after_rollback and :after_commit callbacks
# can be called.
def add_to_transaction
if self.class.connection.add_transaction_record(self)
remember_transaction_record_state
end
raise
end

# Executes +method+ within a transaction and captures its return value as a
Expand All @@ -226,10 +280,59 @@ def rollback_active_record_state!
def with_transaction_returning_status(method, *args)
status = nil
self.class.transaction do
add_to_transaction
status = send(method, *args)
raise ActiveRecord::Rollback unless status
end
status
end

protected

# Save the new record state and id of a record so it can be restored later if a transaction fails.
def remember_transaction_record_state #:nodoc
@_start_transaction_state ||= {}
unless @_start_transaction_state.include?(:new_record)
@_start_transaction_state[:id] = id if has_attribute?(self.class.primary_key)
@_start_transaction_state[:new_record] = @new_record
end
unless @_start_transaction_state.include?(:destroyed)
@_start_transaction_state[:destroyed] = @new_record
end
@_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) + 1
end

# Clear the new record state and id of a record.
def clear_transaction_record_state #:nodoc
if defined?(@_start_transaction_state)
@_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) - 1
remove_instance_variable(:@_start_transaction_state) if @_start_transaction_state[:level] < 1
end
end

# Restore the new record state and id of a record that was previously saved by a call to save_record_state.
def restore_transaction_record_state(force = false) #:nodoc
if defined?(@_start_transaction_state)
@_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) - 1
if @_start_transaction_state[:level] < 1
restore_state = remove_instance_variable(:@_start_transaction_state)
if restore_state
@new_record = restore_state[:new_record]
@destroyed = restore_state[:destroyed]
if restore_state[:id]
self.id = restore_state[:id]
else
@attributes.delete(self.class.primary_key)
@attributes_cache.delete(self.class.primary_key)
end
end
end
end
end

# Determine if a record was created or destroyed in a transaction. State should be one of :new_record or :destroyed.
def transaction_record_state(state) #:nodoc
@_start_transaction_state[state] if defined?(@_start_transaction_state)
end
end
end

2 comments on commit da840d1

@pat
Copy link
Contributor

@pat pat commented on da840d1 May 17, 2010

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So happy this is in AR proper. Thanks Brian et al!

@loe
Copy link
Contributor

@loe loe commented on da840d1 May 17, 2010

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic!

Please sign in to comment.