mirror of https://github.com/rails/rails
Added posibility to open a `read_uncommitted` transaction on SQLite:
- ### Use case I'd like to be able to see changes made by a connection writer within a connection reader before the writer transaction commits (aka `read_uncommitted` transaction isolation level). ```ruby conn1.transaction do Dog.create(name: 'Fido') conn2.transaction do Dog.find(name: 'Fido') # -> Can't see the dog untill conn1 commits the transaction end end ``` Other adapters in Rails (mysql, postgres) already supports multiple types of isolated db transaction. SQLite doesn't support the 4 main ones but it supports `read_uncommitted` and `serializable` (the default one when opening a transaction) ### Solution This PR allow developers to open a `read_uncommitted` transaction by setting the PRAGMA `read_uncommitted` to true for the duration of the transaction. That PRAGMA can only be enabled if the SQLite connection was established with the [shared-cache mode](https://www.sqlite.org/sharedcache.html) This feature can also benefit the framework and we could potentially get rid of the `setup_shared_connection_pool` inside tests which was a solution in the context of a multi-db app so that the reader can see stuff from the open transaction writer but has some [caveats](https://github.com/rails/rails/issues/37765#event-2828609021). ### Edge case Shared-cache mode can be enabled for in memory database as well, however for backward compatibility reasons, SQLite only allows to set the shared-cache mode if the database name is a URI. It won't allow it if the database name is `:memory`; it has to be changed to `file::memory` instead.
This commit is contained in:
parent
e67fdc5aeb
commit
cf5b6199df
|
@ -74,19 +74,37 @@ module ActiveRecord
|
|||
end
|
||||
alias :exec_update :exec_delete
|
||||
|
||||
def begin_isolated_db_transaction(isolation) #:nodoc
|
||||
raise ArgumentError, "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?
|
||||
|
||||
Thread.current.thread_variable_set("read_uncommitted", @connection.get_first_value("PRAGMA read_uncommitted"))
|
||||
@connection.read_uncommitted = true
|
||||
begin_db_transaction
|
||||
end
|
||||
|
||||
def begin_db_transaction #:nodoc:
|
||||
log("begin transaction", "TRANSACTION") { @connection.transaction }
|
||||
end
|
||||
|
||||
def commit_db_transaction #:nodoc:
|
||||
log("commit transaction", "TRANSACTION") { @connection.commit }
|
||||
reset_read_uncommitted
|
||||
end
|
||||
|
||||
def exec_rollback_db_transaction #:nodoc:
|
||||
log("rollback transaction", "TRANSACTION") { @connection.rollback }
|
||||
reset_read_uncommitted
|
||||
end
|
||||
|
||||
private
|
||||
def reset_read_uncommitted
|
||||
read_uncommitted = Thread.current.thread_variable_get("read_uncommitted")
|
||||
return unless read_uncommitted
|
||||
|
||||
@connection.read_uncommitted = read_uncommitted
|
||||
end
|
||||
|
||||
def execute_batch(statements, name = nil)
|
||||
sql = combine_multi_statements(statements)
|
||||
|
||||
|
|
|
@ -325,6 +325,10 @@ module ActiveRecord
|
|||
sql
|
||||
end
|
||||
|
||||
def shared_cache? # :nodoc:
|
||||
@config.fetch(:flags, 0).anybits?(::SQLite3::Constants::Open::SHAREDCACHE)
|
||||
end
|
||||
|
||||
def get_database_version # :nodoc:
|
||||
SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)"))
|
||||
end
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "cases/helper"
|
||||
|
||||
class SQLite3TransactionTest < ActiveRecord::SQLite3TestCase
|
||||
test "shared_cached? is true when cache-mode is enabled" do
|
||||
with_connection(flags: shared_cache_flags) do |conn|
|
||||
assert_predicate(conn, :shared_cache?)
|
||||
end
|
||||
end
|
||||
|
||||
test "shared_cached? is false when cache-mode is disabled" do
|
||||
flags =::SQLite3::Constants::Open::READWRITE | SQLite3::Constants::Open::CREATE
|
||||
|
||||
with_connection(flags: flags) do |conn|
|
||||
assert_not_predicate(conn, :shared_cache?)
|
||||
end
|
||||
end
|
||||
|
||||
test "raises when trying to open a transaction in a isolation level other than `read_uncommitted`" do
|
||||
with_connection do |conn|
|
||||
assert_raises(ArgumentError) do
|
||||
conn.transaction(requires_new: true, isolation: :something) do
|
||||
conn.transaction_manager.materialize_transactions
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "raises when trying to open a read_uncommitted transaction but shared-cache mode is turned off" do
|
||||
with_connection do |conn|
|
||||
error = assert_raises(StandardError) do
|
||||
conn.transaction(requires_new: true, isolation: :read_uncommitted) do
|
||||
conn.transaction_manager.materialize_transactions
|
||||
end
|
||||
end
|
||||
|
||||
assert_match("You need to enable the shared-cache mode", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
test "opens a `read_uncommitted` transaction" do
|
||||
with_connection(flags: shared_cache_flags) do |conn1|
|
||||
conn1.create_table(:zines) { |t| t.column(:title, :string) } if in_memory_db?
|
||||
conn1.transaction do
|
||||
conn1.transaction_manager.materialize_transactions
|
||||
conn1.execute("INSERT INTO zines (title) VALUES ('foo')")
|
||||
|
||||
with_connection(flags: shared_cache_flags) do |conn2|
|
||||
conn2.transaction(joinable: false, isolation: :read_uncommitted) do
|
||||
assert_not_empty(conn2.execute("SELECT * FROM zines WHERE title = 'foo'"))
|
||||
end
|
||||
end
|
||||
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "reset the read_uncommitted PRAGMA when transactions is rolled back" do
|
||||
with_connection(flags: shared_cache_flags) do |conn|
|
||||
conn.transaction(joinable: false, isolation: :read_uncommitted) do
|
||||
assert_not(read_uncommitted?(conn))
|
||||
conn.transaction_manager.materialize_transactions
|
||||
assert(read_uncommitted?(conn))
|
||||
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
assert_not(read_uncommitted?(conn))
|
||||
end
|
||||
end
|
||||
|
||||
test "reset the read_uncommitted PRAGMA when transactions is commited" do
|
||||
with_connection(flags: shared_cache_flags) do |conn|
|
||||
conn.transaction(joinable: false, isolation: :read_uncommitted) do
|
||||
assert_not(read_uncommitted?(conn))
|
||||
conn.transaction_manager.materialize_transactions
|
||||
assert(read_uncommitted?(conn))
|
||||
end
|
||||
|
||||
assert_not(read_uncommitted?(conn))
|
||||
end
|
||||
end
|
||||
|
||||
test "set the read_uncommited PRAGMA to its previous value" do
|
||||
with_connection(flags: shared_cache_flags) do |conn|
|
||||
conn.transaction(joinable: false, isolation: :read_uncommitted) do
|
||||
conn.instance_variable_get(:@connection).read_uncommitted = true
|
||||
assert(read_uncommitted?(conn))
|
||||
conn.transaction_manager.materialize_transactions
|
||||
assert(read_uncommitted?(conn))
|
||||
end
|
||||
|
||||
assert(read_uncommitted?(conn))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def read_uncommitted?(conn)
|
||||
conn.instance_variable_get(:@connection).get_first_value("PRAGMA read_uncommitted") != 0
|
||||
end
|
||||
|
||||
def shared_cache_flags
|
||||
::SQLite3::Constants::Open::READWRITE | SQLite3::Constants::Open::CREATE | ::SQLite3::Constants::Open::SHAREDCACHE | ::SQLite3::Constants::Open::URI
|
||||
end
|
||||
|
||||
def with_connection(options = {})
|
||||
conn_options = options.reverse_merge(
|
||||
database: in_memory_db? ? 'file::memory:' : ActiveRecord::Base.configurations["arunit"][:database]
|
||||
)
|
||||
conn = ActiveRecord::Base.sqlite3_connection(conn_options)
|
||||
|
||||
yield(conn)
|
||||
ensure
|
||||
conn.disconnect! if conn
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue