Skip to content

Commit

Permalink
Omit BEGIN/COMMIT statements for empty transactions
Browse files Browse the repository at this point in the history
If a transaction is opened and closed without any queries being run, we
can safely omit the `BEGIN` and `COMMIT` statements, as they only exist
to modify the connection's behaviour inside the transaction. This
removes the overhead of those statements when saving a record with no
changes, which makes workarounds like `save if changed?` unnecessary.

This implementation buffers transactions inside the transaction manager
and materializes them the next time the connection is used. For this to
work, the adapter needs to guard all connection use with a call to
`materialize_transactions`. Because of this, adapters must opt in to get
this new behaviour by implementing `supports_lazy_transactions?`.

If `raw_connection` is used to get a reference to the underlying
database connection, the behaviour is disabled and transactions are
opened eagerly, as we can't know how the connection will be used.
However when the connection is checked back into the pool, we can assume
that the application won't use the reference again and reenable lazy
transactions. This prevents a single `raw_connection` call from
disabling lazy transactions for the lifetime of the connection.
  • Loading branch information
eugeneius committed Aug 13, 2018
1 parent f2970a0 commit 0ac81ee
Show file tree
Hide file tree
Showing 17 changed files with 185 additions and 17 deletions.
Expand Up @@ -259,7 +259,9 @@ def transaction(requires_new: nil, isolation: nil, joinable: true)

attr_reader :transaction_manager #:nodoc:

delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction, :commit_transaction, :rollback_transaction, to: :transaction_manager
delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction,
:commit_transaction, :rollback_transaction, :materialize_transactions,
:disable_lazy_transactions!, :enable_lazy_transactions!, to: :transaction_manager

def transaction_open?
current_transaction.open?
Expand Down
Expand Up @@ -91,12 +91,14 @@ def add_record(record); end
end

class Transaction #:nodoc:
attr_reader :connection, :state, :records, :savepoint_name
attr_reader :connection, :state, :records, :savepoint_name, :isolation_level

def initialize(connection, options, run_commit_callbacks: false)
@connection = connection
@state = TransactionState.new
@records = []
@isolation_level = options[:isolation]
@materialized = false
@joinable = options.fetch(:joinable, true)
@run_commit_callbacks = run_commit_callbacks
end
Expand All @@ -105,6 +107,14 @@ def add_record(record)
records << record
end

def materialize!
@materialized = true
end

def materialized?
@materialized
end

def rollback_records
ite = records.uniq
while record = ite.shift
Expand Down Expand Up @@ -141,47 +151,54 @@ def open?; !closed?; end
end

class SavepointTransaction < Transaction
def initialize(connection, savepoint_name, parent_transaction, options, *args)
super(connection, options, *args)
def initialize(connection, savepoint_name, parent_transaction, *args)
super(connection, *args)

parent_transaction.state.add_child(@state)

if options[:isolation]
if isolation_level
raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
end
connection.create_savepoint(@savepoint_name = savepoint_name)

@savepoint_name = savepoint_name
end

def materialize!
connection.create_savepoint(savepoint_name)
super
end

def rollback
connection.rollback_to_savepoint(savepoint_name)
connection.rollback_to_savepoint(savepoint_name) if materialized?
@state.rollback!
end

def commit
connection.release_savepoint(savepoint_name)
connection.release_savepoint(savepoint_name) if materialized?
@state.commit!
end

def full_rollback?; false; end
end

class RealTransaction < Transaction
def initialize(connection, options, *args)
super
if options[:isolation]
connection.begin_isolated_db_transaction(options[:isolation])
def materialize!
if isolation_level
connection.begin_isolated_db_transaction(isolation_level)
else
connection.begin_db_transaction
end

super
end

def rollback
connection.rollback_db_transaction
connection.rollback_db_transaction if materialized?
@state.full_rollback!
end

def commit
connection.commit_db_transaction
connection.commit_db_transaction if materialized?
@state.full_commit!
end
end
Expand All @@ -190,6 +207,9 @@ class TransactionManager #:nodoc:
def initialize(connection)
@stack = []
@connection = connection
@has_unmaterialized_transactions = false
@materializing_transactions = false
@lazy_transactions_enabled = true
end

def begin_transaction(options = {})
Expand All @@ -203,11 +223,41 @@ def begin_transaction(options = {})
run_commit_callbacks: run_commit_callbacks)
end

transaction.materialize! unless @connection.supports_lazy_transactions? && lazy_transactions_enabled?
@stack.push(transaction)
@has_unmaterialized_transactions = true if @connection.supports_lazy_transactions?
transaction
end
end

def disable_lazy_transactions!
materialize_transactions
@lazy_transactions_enabled = false
end

def enable_lazy_transactions!
@lazy_transactions_enabled = true
end

def lazy_transactions_enabled?
@lazy_transactions_enabled
end

def materialize_transactions
return if @materializing_transactions
return unless @has_unmaterialized_transactions

@connection.lock.synchronize do
begin
@materializing_transactions = true
@stack.each { |t| t.materialize! unless t.materialized? }
ensure
@materializing_transactions = false
end
@has_unmaterialized_transactions = false
end
end

def commit_transaction
@connection.lock.synchronize do
transaction = @stack.last
Expand Down
Expand Up @@ -80,6 +80,8 @@ class AbstractAdapter
attr_reader :schema_cache, :owner, :logger, :prepared_statements, :lock
alias :in_use? :owner

set_callback :checkin, :after, :enable_lazy_transactions!

def self.type_cast_config_to_integer(config)
if config.is_a?(Integer)
config
Expand Down Expand Up @@ -338,6 +340,10 @@ def supports_foreign_tables?
false
end

def supports_lazy_transactions?
false
end

# This is meant to be implemented by the adapters that support extensions
def disable_extension(name)
end
Expand Down Expand Up @@ -449,6 +455,7 @@ def verify!
# This is useful for when you need to call a proprietary method such as
# PostgreSQL's lo_* methods.
def raw_connection
disable_lazy_transactions!
@connection
end

Expand Down
Expand Up @@ -180,6 +180,8 @@ def explain(arel, binds = [])

# Executes the SQL statement in the context of this connection.
def execute(sql, name = nil)
materialize_transactions

log(sql, name) do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@connection.query(sql)
Expand Down
Expand Up @@ -29,6 +29,8 @@ def execute(sql, name = nil)
end

def exec_query(sql, name = "SQL", binds = [], prepare: false)
materialize_transactions

if without_prepared_statement?(binds)
execute_and_free(sql, name) do |result|
ActiveRecord::Result.new(result.fields, result.to_a) if result
Expand All @@ -41,6 +43,8 @@ def exec_query(sql, name = "SQL", binds = [], prepare: false)
end

def exec_delete(sql, name = nil, binds = [])
materialize_transactions

if without_prepared_statement?(binds)
execute_and_free(sql, name) { @connection.affected_rows }
else
Expand Down
Expand Up @@ -58,6 +58,10 @@ def supports_savepoints?
true
end

def supports_lazy_transactions?
true
end

# HELPER METHODS ===========================================

def each_hash(result) # :nodoc:
Expand Down
Expand Up @@ -58,6 +58,8 @@ def result_as_array(res) #:nodoc:

# Queries the database and returns the results in an Array-like object
def query(sql, name = nil) #:nodoc:
materialize_transactions

log(sql, name) do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
result_as_array @connection.async_exec(sql)
Expand All @@ -70,6 +72,8 @@ def query(sql, name = nil) #:nodoc:
# Note: the PG::Result object is manually memory managed; if you don't
# need it specifically, you may want consider the <tt>exec_query</tt> wrapper.
def execute(sql, name = nil)
materialize_transactions

log(sql, name) do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@connection.async_exec(sql)
Expand Down
Expand Up @@ -326,6 +326,10 @@ def supports_pgcrypto_uuid?
postgresql_version >= 90400
end

def supports_lazy_transactions?
true
end

def get_advisory_lock(lock_id) # :nodoc:
unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63
raise(ArgumentError, "PostgreSQL requires advisory lock ids to be a signed 64 bit integer")
Expand Down Expand Up @@ -597,6 +601,8 @@ def execute_and_clear(sql, name, binds, prepare: false)
end

def exec_no_cache(sql, name, binds)
materialize_transactions

type_casted_binds = type_casted_binds(binds)
log(sql, name, binds, type_casted_binds) do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
Expand All @@ -606,6 +612,8 @@ def exec_no_cache(sql, name, binds)
end

def exec_cache(sql, name, binds)
materialize_transactions

stmt_key = prepare_statement(sql)
type_casted_binds = type_casted_binds(binds)

Expand Down
Expand Up @@ -186,6 +186,10 @@ def supports_explain?
true
end

def supports_lazy_transactions?
true
end

# REFERENTIAL INTEGRITY ====================================

def disable_referential_integrity # :nodoc:
Expand All @@ -212,6 +216,8 @@ def explain(arel, binds = [])
end

def exec_query(sql, name = nil, binds = [], prepare: false)
materialize_transactions

type_casted_binds = type_casted_binds(binds)

log(sql, name, binds, type_casted_binds) do
Expand Down Expand Up @@ -252,6 +258,8 @@ def last_inserted_id(result)
end

def execute(sql, name = nil) #:nodoc:
materialize_transactions

log(sql, name) do
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@connection.execute(sql)
Expand Down
1 change: 1 addition & 0 deletions activerecord/test/cases/adapter_test.rb
Expand Up @@ -10,6 +10,7 @@ module ActiveRecord
class AdapterTest < ActiveRecord::TestCase
def setup
@connection = ActiveRecord::Base.connection
@connection.materialize_transactions
end

##
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/cases/adapters/mysql2/connection_test.rb
Expand Up @@ -170,6 +170,8 @@ def test_mysql_set_session_variable_to_default
end

def test_logs_name_show_variable
ActiveRecord::Base.connection.materialize_transactions
@subscriber.logged.clear
@connection.show_variable "foo"
assert_equal "SCHEMA", @subscriber.logged[0][1]
end
Expand Down
Expand Up @@ -4,6 +4,8 @@

class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase
def setup
ActiveRecord::Base.connection.materialize_transactions

ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do
def execute(sql, name = nil) sql end
end
Expand Down
Expand Up @@ -15,8 +15,9 @@ class NonExistentTable < ActiveRecord::Base
def setup
super
@subscriber = SQLSubscriber.new
@subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber)
@connection = ActiveRecord::Base.connection
@connection.materialize_transactions
@subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber)
end

def teardown
Expand Down
1 change: 1 addition & 0 deletions activerecord/test/cases/log_subscriber_test.rb
Expand Up @@ -44,6 +44,7 @@ def debug(progname = nil, &block)
def setup
@old_logger = ActiveRecord::Base.logger
Developer.primary_key
ActiveRecord::Base.connection.materialize_transactions
super
ActiveRecord::LogSubscriber.attach_to(:active_record)
end
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/cases/test_case.rb
Expand Up @@ -31,6 +31,7 @@ def teardown
end

def capture_sql
ActiveRecord::Base.connection.materialize_transactions
SQLCounter.clear_log
yield
SQLCounter.log_all.dup
Expand All @@ -48,6 +49,7 @@ def assert_sql(*patterns_to_match)

def assert_queries(num = 1, options = {})
ignore_none = options.fetch(:ignore_none) { num == :any }
ActiveRecord::Base.connection.materialize_transactions
SQLCounter.clear_log
x = yield
the_log = ignore_none ? SQLCounter.log_all : SQLCounter.log
Expand Down
2 changes: 1 addition & 1 deletion activerecord/test/cases/transaction_isolation_test.rb
Expand Up @@ -11,7 +11,7 @@ class Tag < ActiveRecord::Base

test "setting the isolation level raises an error" do
assert_raises(ActiveRecord::TransactionIsolationError) do
Tag.transaction(isolation: :serializable) {}
Tag.transaction(isolation: :serializable) { Topic.connection.materialize_transactions }
end
end
end
Expand Down

0 comments on commit 0ac81ee

Please sign in to comment.