Use SQLite `IMMEDIATE` transactions when possible.

Transactions run against the SQLite3 adapter default to IMMEDIATE mode to
improve concurrency support and avoid busy exceptions.

Fixture transactions use DEFERRED mode transactions as all `joinable`
transactions become DEFERRED transactions.
This commit is contained in:
Stephen Margheim 2024-05-31 00:47:22 +02:00 committed by Jean Boussier
parent ac0fa17eae
commit 1e2c9048c4
7 changed files with 58 additions and 20 deletions

View File

@ -1,3 +1,9 @@
* Use SQLite `IMMEDIATE` transactions when possible.
Transactions run against the SQLite3 adapter default to IMMEDIATE mode to improve concurrency support and avoid busy exceptions.
*Stephen Margheim*
* Raise specific exception when a connection is not defined. * Raise specific exception when a connection is not defined.
The new `ConnectionNotDefined` exception provides connection name, shard and role accessors indicating the details of the connection that was requested. The new `ConnectionNotDefined` exception provides connection name, shard and role accessors indicating the details of the connection that was requested.

View File

@ -411,6 +411,14 @@ module ActiveRecord
# Begins the transaction (and turns off auto-committing). # Begins the transaction (and turns off auto-committing).
def begin_db_transaction() end def begin_db_transaction() end
def begin_deferred_transaction(isolation_level = nil) # :nodoc:
if isolation_level
begin_isolated_db_transaction(isolation_level)
else
begin_db_transaction
end
end
def transaction_isolation_levels def transaction_isolation_levels
{ {
read_uncommitted: "READ UNCOMMITTED", read_uncommitted: "READ UNCOMMITTED",

View File

@ -448,10 +448,14 @@ module ActiveRecord
# = Active Record Real \Transaction # = Active Record Real \Transaction
class RealTransaction < Transaction class RealTransaction < Transaction
def materialize! def materialize!
if isolation_level if joinable?
connection.begin_isolated_db_transaction(isolation_level) if isolation_level
connection.begin_isolated_db_transaction(isolation_level)
else
connection.begin_db_transaction
end
else else
connection.begin_db_transaction connection.begin_deferred_transaction(isolation_level)
end end
super super

View File

@ -65,25 +65,16 @@ module ActiveRecord
end end
alias :exec_update :exec_delete alias :exec_update :exec_delete
def begin_isolated_db_transaction(isolation) # :nodoc: def begin_deferred_transaction(isolation = nil) # :nodoc:
raise TransactionIsolationError, "SQLite3 only supports the `read_uncommitted` transaction isolation level" if isolation != :read_uncommitted internal_begin_transaction(:deferred, isolation)
raise StandardError, "You need to enable the shared-cache mode in SQLite mode before attempting to change the transaction isolation level" unless shared_cache? end
with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn| def begin_isolated_db_transaction(isolation) # :nodoc:
ActiveSupport::IsolatedExecutionState[:active_record_read_uncommitted] = conn.get_first_value("PRAGMA read_uncommitted") internal_begin_transaction(:deferred, isolation)
conn.read_uncommitted = true
begin_db_transaction
end
end end
def begin_db_transaction # :nodoc: def begin_db_transaction # :nodoc:
log("begin transaction", "TRANSACTION") do internal_begin_transaction(:immediate, nil)
with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn|
result = conn.transaction
verified!
result
end
end
end end
def commit_db_transaction # :nodoc: def commit_db_transaction # :nodoc:
@ -114,6 +105,25 @@ module ActiveRecord
end end
private private
def internal_begin_transaction(mode, isolation)
if isolation
raise TransactionIsolationError, "SQLite3 only supports the `read_uncommitted` transaction isolation level" if isolation != :read_uncommitted
raise StandardError, "You need to enable the shared-cache mode in SQLite mode before attempting to change the transaction isolation level" unless shared_cache?
end
log("begin #{mode} transaction", "TRANSACTION") do
with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn|
if isolation
ActiveSupport::IsolatedExecutionState[:active_record_read_uncommitted] = conn.get_first_value("PRAGMA read_uncommitted")
conn.read_uncommitted = true
end
result = conn.transaction(mode)
verified!
result
end
end
end
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: false) def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: false)
log(sql, name, async: async) do |notification_payload| log(sql, name, async: async) do |notification_payload|
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn| with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|

View File

@ -120,7 +120,11 @@ module ActiveRecord
end end
@config[:strict] = ConnectionAdapters::SQLite3Adapter.strict_strings_by_default unless @config.key?(:strict) @config[:strict] = ConnectionAdapters::SQLite3Adapter.strict_strings_by_default unless @config.key?(:strict)
@connection_parameters = @config.merge(database: @config[:database].to_s, results_as_hash: true) @connection_parameters = @config.merge(
database: @config[:database].to_s,
results_as_hash: true,
default_transaction_mode: :immediate,
)
@use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true
end end

View File

@ -798,7 +798,7 @@ class PessimisticLockingTest < ActiveRecord::TestCase
a = Thread.new do a = Thread.new do
t0 = Time.now t0 = Time.now
Person.transaction do Person.transaction(joinable: false) do
yield yield
b_wakeup.set b_wakeup.set
a_wakeup.wait a_wakeup.wait

View File

@ -1386,6 +1386,12 @@ class TransactionTest < ActiveRecord::TestCase
Topic.reset_column_information Topic.reset_column_information
end end
end end
def test_sqlite_default_transaction_mode_is_immediate
assert_queries_match(/BEGIN IMMEDIATE TRANSACTION/i, include_schema: false) do
Topic.transaction { Topic.lease_connection.materialize_transactions }
end
end
end end
def test_transactions_state_from_rollback def test_transactions_state_from_rollback