Ensure SQLite transaction default to IMMEDIATE mode#50371
Conversation
|
CI failures look legit: |
|
@byroot: Interesting. I cannot reproduce locally. I'm guessing Buildkite runs tests in parallel? How could I best mimic Buildkite's test setup on my local machine? I need to reproduce to start hunting down the best way to get tests passing on Buildkite. |
Not as far as I know. However it runs test on Linux, perhaps it's not linking to the same version of sqlite3? |
activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
Outdated
Show resolved
Hide resolved
dba5e67 to
4dc199a
Compare
|
My first experiment failed, but I have honed in on where the issue arises, but without getting to the point of understanding precisely what the issue is. The issue has to do with setting up fixtures. My bet is that the I can see that we open transactions in 4 connections, 3 of which are for the same database file (fixtures.sqlite3). And the lock exception is thrown on the last connection. I'm not immediately certain why we need to have multiple connections to the same database file. But, regardless, I haven't yet found why trying to open an immediate transaction on the final connection throws a "database locked" exception. Will keep debugging. |
1106c0a to
96ca4e6
Compare
|
Making progress. I'm down to a few sporadic failures where the tests simply take longer than the |
|
What is the default busy timeout? Should it be higher? |
For tests, Rails sets up connections for the fixtures with a 5 second timeout (see here). This matches the 5 second default that Rails generates for a SQLite I'll try bumping the test timeout and seeing if that resolves the tests, tho it will be flaky of course. I can also keep working on trying to shore up the underlying logic for handling fixtures in tests. If we don't keep so many connections to the same database in our tests, this problem goes away more resiliently. |
96ca4e6 to
00417ee
Compare
|
Still failing. It is a pretty consistent, and small, collection of test cases that are failing. My current hypothesis is that these tests have connections that aren't covered by the I think that we may need to pair this PR with a refactor of the |
32786a0 to
989c876
Compare
|
@dhh: Unrelated |
ef7a7fa to
5d531a1
Compare
|
Ah, I pushed what I thought was an irrelevant change (removing the However, it seems that this PR is blocked for now. |
|
An alternative thought I just had: We could go another direction altogether and only have the automatically created transactions used to wrap ActiveRecord's write operations run as |
|
@dhh: I found the entry point for ActiveRecord write operation transactions. I have refactored the entire PR to only make these operations INMEDIATE transactions. So, the default of deferred transactions remains the same. This was a long road, as I naively presumed that both automatic and manual transactions would basically always have a write operation. After slogging thru tests, I realize that a more isolated change is probably a more solid and resilient change for Rails. There are the flaky |
| ensure_finalize = !connection.transaction_open? | ||
|
|
||
| connection.transaction do | ||
| connection.transaction(mode: :immediate) do |
There was a problem hiding this comment.
Don't want to have a SQLite3 specific mode concern leak all the way into the other adapters and the root transaction setup. There's gotta be a better way with less collateral damage.
There was a problem hiding this comment.
When I had focused on just the fixtures, I introduced use_deferred_transaction_mode!, use_immediate_transaction_mode!, and use_default_transaction_mode! methods to the SQLite3 adapter and called them before calling #transaction. This original implementation was to avoid precisely this leak, tho it traded that with "spooky action as a distance". When I pivoted to this approach, I thought that some users are likely to want control over the transaction mode when starting a transaction manually. From this use-case point of view, I thought that the "spooky action at a distance" approach of:
connection.use_immediate_transaction_mode!
connection.transaction do
endfelt off.
I am open to going in that direction if you think that the conceptual leak of the reality that SQLite has transaction modes and other DBMSes don't is a larger issue than the interface for end-user control of SQLite's transaction modes.
I did make sure to ensure that none of the other adapters change their interface, only their method signature. A couple other possibilities:
- implement a generic
options = {}interface for thetransactionmethod where currently only the SQLite adapter looks into the hash to see if a:modewas set - keep the
mode = nilinterface for thetransactionmethod, but explicitlyraiseif that option is passed into the methods of the other adapters
Or, again, we could revert to the connection.use_immediate_transaction_mode! approach I was using earlier. Of course, I'm also open to any other suggestions.
|
Other option is allowing the adapters to be able to take over that transaction method. |
True. I can try that out and push it up and you can consider that as a wholistic diff. Will do that shortly. |
8dece5c to
93fc4a5
Compare
|
@dhh: This implementation doesn't leak the concept of "transaction modes" to any other adapters. WDYT? (as always, |
|
@matthewd @byroot: I resolved conflicts on the CHANGELOG, and I will squash commits once we agree on a direction. I'd greatly appreciate your thoughts here. I updated the initial post to match the current state of affairs, and I'm happy to answer any questions you might have. This really is a big pain for production SQLite applications, and I'd love to find a path forward here that works for everyone. |
5482aa1 to
9fa0de7
Compare
|
@fractaledmind - we've been running ONCE Campfire (https://once.com/campfire) with this setting since it was released and it's worked well so, +1 for this. With deferred transactions, busy exceptions were a constant problem. With immediate transactions plus a GVL releasing timeout they were virtually non-existent. I guess if you had an app that heavily relied on read only transactions, deferred mode might be preferable for those. In that case I think you'd still want this setting as the default so that transactions created internally for writes by ActiveRecord are not deferred. So in that case it would be nice to be able to set the mode per-transaction though that would leak sqlite specific settings into the |
9fa0de7 to
1cc8528
Compare
|
I modified your PR so it's enabled in the adapter by default rather than in the default generated config, as to have coverage on CI. But it's causing issues with transactional fixtures, so I'll need to dig more into this when I got time. |
@byroot: Sorry for the delayed response, been on vacation. I had originally enabled this in the adapter as well and ran into the same issues. Basically, Rails' transaction fixtures require DEFERRED transactions. I began fixing this by introducing a keyword option to the Eventually, I landed on trying to simply push this out of Rails and into the application. I need to add back a mechanism for specifying a specific transaction mode (either an additional kwarg in If you'd like, I can write up a more detailed explanation of the difficulty of patching transactional fixtures to use DEFERRED transactions, while having the rest of AR default to IMMEDIATE transactions, given the current state of that code (TL;DR the abstraction doesn't provide a clean place to make the change for only transactional fixtures, as the transaction itself has been moved up into AR now). Finding some solution for this is very important for Rails 8 tho, so whatever it takes, I'm here to do whatever I can. |
|
The fixtures transaction uses |
1cc8528 to
71ef352
Compare
|
@byroot: Interesting thought! This helped me finalize a comprehensive approach. I just pushed a new commit which handles 3 details:
This provides the full suite of tools to ensure applications can be built precisely as needed, but with productive defaults. A couple of notes:
|
7a792ff to
80270fc
Compare
|
|
||
| # Begins the transaction (and turns off auto-committing). | ||
| def begin_db_transaction() end | ||
| def begin_db_transaction(mode = nil) end |
There was a problem hiding this comment.
begin_db_transaction is public API I'd rather not change it.
I'd prefer to add a begin_deferred_db_transaction that is # :nodoc: and only for internal use. On adapters other than Sqlite3 it would just be an alias to begin_db_transaction.
It's also more consistent with begin_isolated_db_transaction
There was a problem hiding this comment.
We need a way to compose deferred and isolated transactions. You can see this need in the changed test in activerecord/test/cases/adapters/sqlite3/transaction_test.rb. Simply having begin_deferred and begin_isolated doesn't quite provide enough for such composition. I see the point of not wanting to change the public API of begin_db_transaction. Maybe an alternative is to move away (first with backwards compat) from begin_*_db_transaction methods and add attr_accessors for different transaction modifiers that begin_db_transaction uses to craft the desired transaction?
There was a problem hiding this comment.
We need a way to compose deferred and isolated transactions.
If app developers have to concern themselves about whether a transaction should be deffered or immediate, then I'm not sure deferred is a suitable default.
That's not a concern I'm personally willing to expose to users, even more so because it has no equivalent on other adapters. To me that's a deal breaker. If someone else feel strongly about it I'm not gonna oppose, but I won't merge that.
There was a problem hiding this comment.
App developers will rarely need to concern themselves with whether a transaction should be deferred or immediate, in much the same way they rarely need to concern themselves with whether a transaction should be joinable or not. The main situation in which an app developer would need to concern himself is in fact shared between these concerns: nested transactions. And the test in activerecord/test/cases/adapters/sqlite3/transaction_test.rb demonstrates an example:
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
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
endNow, it should be rare indeed that a developer needs a real (joinable: false), isolated, nested transaction. But if they ever do, that transaction must be deferred when the adapter is SQLite. Otherwise, the database will immediately throw an error and the code will never execute.
I agree to not exposing mode: :deferred | :immediate directly through the #transaction method. I see your point. My point is only that we cannot simply add a begin_deferred_db_transaction method that acts as a sibling of the begin_isolated_db_transaction method naively, because sometimes you will need to begin both a deferred and an isolated transaction (like in the test case above).
So, the naive code that I originally wrote in response to your review isn't sufficient:
class RealTransaction < Transaction
def materialize!
if isolation_level
connection.begin_isolated_db_transaction(isolation_level)
elsif !joinable?
connection.begin_deferred_db_transaction
else
connection.begin_db_transaction
end
super
end
# ...
endThis code doesn't allow that test case to pass. We aren't exposing anything about SQLite's transaction modes to the app developer; the test case looks precisely as it does now on main, but it does fail and will always fail unless we can set up a transaction that is both deferred and isolated.
I wanted to share that realization with you as I made it, and share the first thought that come to my mind for how to slightly evolve the codebase to accommodate composing transaction behaviors, instead of simply branching between them. I will finish my update to this branch today and push my suggestion for how this could look. You can then review that code. Sorry for adding confusion by talking about a problem without pushing some code that provides at least one possibility of a solution.
Small addendum: Postgres (I haven't studied MySQL to know either way) has a similar concept as deferred transactions, it just isn't something that is a concern of ActiveRecord:
The
DEFERRABLEtransaction property has no effect unless the transaction is alsoSERIALIZABLEandREAD ONLY. When all three of these properties are selected for a transaction, the transaction may block when first acquiring its snapshot, after which it is able to run without the normal overhead of aSERIALIZABLEtransaction and without any risk of contributing to or being canceled by a serialization failure. This mode is well suited for long-running reports or backups.
— https://www.postgresql.org/docs/16/sql-set-transaction.html
Not suggesting that we should in fact used mode: to the #transaction method, but only sharing that this concept isn't completely unique to SQLite. It is only unique to SQLite how essential the difference between the two modes is.
There was a problem hiding this comment.
in much the same way they rarely need to concern themselves with whether a transaction should be joinable or not.
Again. joinable isn't a public property, and public API doesn't allow you to request a non-joinable transaction, it's a purely internal concept. So the comparison doesn't hold.
I'm fine with the adapter having some internal logic for when to use immediate or deferred based on wehther it's nested or other criteria, but the user shouldn't have to think about it.
There was a problem hiding this comment.
Ok, I think I finally understand what you mean by "public". I was originally interpreting that as "not publicly accessible", but I am seeing that you mean instead "not publicly documented". Gotcha. Again, sorry for my confusion. I think we are on the same page now as to the public interface for #transaction, and I will get my code finished today or this weekend so you can review my attempt to support that interface and the internal mechanics it requires.
Thanks again for the helpful reviews. I appreciate it.
There was a problem hiding this comment.
@byroot: I have introduced a nodoc method that no-ops for MySQL and PG called mark_transaction_deferred that I call at the start of RealTransaction#materialize!. This allows to compose isolation and deferral in a single transaction, when needed (as in the one test), but doesn't expose any of this externally. All transactions default to immediate and only fixture transactions use deferred via joinable
There was a problem hiding this comment.
Alright, I refactored that further but I think it's good to go now.
ed27fcf to
818e17e
Compare
818e17e to
de70b95
Compare
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.
de70b95 to
1e2c904
Compare
activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb
Show resolved
Hide resolved
These are default built-in since Rails 8 See: rails/rails#50371
These are default built-in since Rails 8 See: rails/rails#50371
These are default built-in since Rails 8 See: rails/rails#50371

Motivation / Background
As SQLite's popularity grows as a production database engine for Rails applications, so does the need for robust and resilient default configuration. One of the most common issues faced when using SQLite in a Rails application are the occasional ActiveRecord::StatementInvalid (SQLite3::BusyException: database is locked) exceptions. These occur when a DEFERRED transaction attempts to acquire the SQLite database lock in the middle of a transaction once hitting a write query while another connection holds the database lock. Since this occurs in the middle of a transaction, SQLite does not attempt to retry to transaction by calling the set
busy_handler/busy_timeoutcallback, but instead immediately errors with a busy exception.Detail
This PR has considered two different approaches over the course of its existence:
DEFERREDtoIMMEDIATEDEFERREDtoIMMEDIATEfor transactions that Rails uses to wrap ActiveRecord write operationsVarious test failures where tests are manually creating transactions, along with a stated preference to not expose a generic
modeoption to the#transactionmethod, which would have no meaning or purpose for other adapters, led me to go with option 2.With option 2,
ActiveRecord::Base#with_transaction_returning_statusnow callsAdapter#transaction_returning_statusinstead ofAdapter#transactiondirectly. By default, that method is simply as alias, but the SQLite3 adapter implements thetransaction_returning_statusmethod to ensure that the immediate transaction mode is used. Transaction mode setting is done via theuse_*_transaction_mode!methods added to the SQLite3 adapter, which thetest_fixtures.rbmodule uses as well to ensure that fixture transactions always use deferred transactions.Additional information
Alongside #50370, this PR stabilizes SQLite's ability to handle concurrency without throwing intermittent but frequent busy exceptions.
Checklist
Before submitting the PR make sure the following are checked:
[Fix #issue-number]