fix: lock the actor instance by primary key - #70
Merged
Merged
Conversation
An enqueue used create_or_find_by!, which inserts first. For an actor that already exists, every enqueue paid an insert that failed on the unique key, a transaction restart, and two locking reads. Find the row first, then lock it by its primary key. A steady-state enqueue now issues 10 statements instead of 12, and holds the row for one statement less. This also removes a deadlock. A failed insert leaves a shared lock on the identity index, and MySQL keeps that lock across a savepoint rollback. The old code escaped this only when the insert was the first statement of the transaction, because Active Record then restarts the transaction instead of the savepoint. Callers that already wrote, such as the executor and the reminder scheduler, got a real savepoint and deadlocked when they created the same actor at the same time. Four of eight concurrent callers failed that way before this change. The mailbox now reads the winning row in shared mode after a duplicate key, and locks it by primary key. MySQL needs the shared read because it defaults to repeatable read, and a consistent read cannot see the winning row. PostgreSQL and SQLite take no shared lock, because a share lock there creates the same upgrade deadlock it prevents on MySQL. Validate with bundle exec rake test on SQLite, PostgreSQL, mysql2, and Trilogy, and with bundle exec rake standard rubocop rbs steep security.
|
Put each lookup at its only call site, and drop the two helpers that forwarded a single query. Assert that every worker in the create race test finishes, and kill any worker that outlives its timeout, so a worker cannot hold a pooled connection while the assertions run.
Owner
Author
|
@greptileai review |
Put the savepoint and the duplicate-key recovery in the one method that uses them. Bind the identity attributes once, so the same pair no longer repeats across three queries.
Owner
Author
|
@greptileai review |
The create race test capped each worker at 30 seconds. SQLite retries a busy write up to lock_retry_attempts times, and every retry waits out the 5 second busy handler, so a starved worker can run for about a minute before it does any real work. The cap sat below that legitimate worst case, and CI failed on Rails 7.1 and 7.2 while the same jobs passed in a parallel run of the same commit. Raise the budget to 180 seconds, so it reports a worker that hangs rather than one that waits. Confirmed by forcing a worker to sleep past a shortened budget and watching the assertion fail.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
SolidObjects::Mailbox#enqueueheld the actor instance row longer than itneeded to, and could deadlock on MySQL.
find_or_create_instancecalledInstance.create_or_find_by!, which insertsfirst. For an actor that already exists, which is every message after the
first, each enqueue paid an insert that failed on the unique key, a transaction
restart, and then two locking reads. The second locking read came from
instance.lock!, on a row the transaction already held.Statement counts
Measured with
sql.active_record, for one enqueue against an actor thatalready exists.
Statements that touch
solid_objects_instancesdrop from 4 to 3. The failedinsert and the transaction restart are gone. The row lock is now held across 7
statements rather than 8.
SQLite started one higher because it cannot restart a transaction in place. It
issued
ROLLBACKand a secondBEGINwhere the servers issueROLLBACK AND CHAIN.The deadlock
A duplicate-key insert leaves a shared lock on
idx_so_instances_identity.MySQL keeps that lock across a savepoint rollback, so the following
SELECT ... FOR UPDATEasks to upgrade shared to exclusive on the same indexrecord. Two callers that do this at once deadlock.
The old code escaped this only by accident. Active Record restarts the parent
transaction instead of opening a savepoint when that parent has not written
yet, and a restart releases the lock. Any caller that had already written got a
real savepoint and no release. The executor, the reminder scheduler, and the
effect executor all write before they enqueue.
On unmodified
main, eight concurrent callers that each hold a writtentransaction and create the same actor produce four
ActiveRecord::Deadlockedfailures.
test/integration/enqueue_test.rbcovers this and fails onmainfor that reason.
What changed
primary key also keeps the enqueue path off the identity index, which is
where the shared locks collect.
instance.lock!. Every path out offind_or_create_instancenow returns a locked row, and a row that disappears still raises
ActiveRecord::RecordNotFound, whichwith_instance_retryhandles.transaction on PostgreSQL, which this gem supports.
lock it by primary key. MySQL defaults to repeatable read, so a consistent
read cannot see a row committed after the snapshot. The shared read costs
nothing there because the failed insert already holds that shared lock.
PostgreSQL and SQLite take no shared lock: PostgreSQL defaults to read
committed and needs no current read, and a share lock there creates the very
upgrade deadlock it prevents on MySQL. This is the new
shared_lockadaptermethod, shaped like the existing
claim_lock.Tests
test/integration/enqueue_statement_count_test.rbis new. It asserts asteady-state enqueue issues no insert to
solid_objects_instances, opens onetransaction and never restarts it, touches the instance row three times, and
issues a fixed total. All four assertions fail on
main.test/integration/enqueue_test.rbgains concurrent enqueue to an actor thatalready exists, concurrent creation from callers that already wrote, and the
retry limit that raises
ActorDestroyed. The existing create race now alsoasserts exactly one instance row.
Each new test was confirmed to fail without the change. The deadlock test fails
three times out of three on
mainwith Trilogy.Validation
bundle exec rake testpasses on all four supported combinations. Skip countsmatch
mainexactly, so nothing became a silent skip.bundle exec rake standard rubocop rbs steep securitypasses. Steep reports notype error and Brakeman reports no warning.
sig/generated/is regenerated.Compatibility
No migration and no API change.
shared_lockis additive and defaults tonil. A host application that runs PostgreSQL at repeatable read gets aRecordNotFoundretry on a lost create race rather than a shared read, whichwith_instance_retryalready absorbs.Not in this change
One lock acquisition per message in the drain is left alone deliberately. Note
that the count is about two per message, not one:
Activation#claim_next_messageand
Executor#completeeach open their ownfenced_transaction. A 39-messagedrain issues 81
FOR UPDATEstatements against the instance row. Batchingwould change failure isolation, attempt counting, and poison-message ordering,
and it would not make the lock fair, which is the actual cause of the observed
starvation. It belongs in its own change.
Release
This branch also cuts 0.15.2.
lib/solid_objects/version.rb,CHANGELOG.md,and
Gemfile.lockare updated in a separate commit, andbundle exec rakepasses on the released tree. Tag
v0.15.2after this merges to publish.