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:
Edouard CHIN 2019-11-25 17:19:44 +01:00
parent e67fdc5aeb
commit cf5b6199df
3 changed files with 140 additions and 0 deletions

View File

@ -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)

View File

@ -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

View File

@ -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