Skip to content

fix: lock the actor instance by primary key - #70

Merged
cardmagic merged 5 commits into
mainfrom
fix/enqueue-instance-lookup
Sep 21, 2026
Merged

cardmagic merged 5 commits into
mainfrom
fix/enqueue-instance-lookup

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Why

SolidObjects::Mailbox#enqueue held the actor instance row longer than it
needed to, and could deadlock on MySQL.

find_or_create_instance called Instance.create_or_find_by!, which inserts
first. 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 that
already exists.

Database Before After
MySQL 8.4 (mysql2 and Trilogy) 12 10
PostgreSQL 18 12 10
SQLite 13 10

Statements that touch solid_objects_instances drop from 4 to 3. The failed
insert 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 ROLLBACK and a second BEGIN where the servers issue
ROLLBACK 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 UPDATE asks to upgrade shared to exclusive on the same index
record. 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 written
transaction and create the same actor produce four ActiveRecord::Deadlocked
failures. test/integration/enqueue_test.rb covers this and fails on main
for that reason.

What changed

  • Read the instance row without a lock, then lock it by primary key. Locking by
    primary key also keeps the enqueue path off the identity index, which is
    where the shared locks collect.
  • Drop the second instance.lock!. Every path out of find_or_create_instance
    now returns a locked row, and a row that disappears still raises
    ActiveRecord::RecordNotFound, which with_instance_retry handles.
  • Keep a savepoint on the create path. A failed statement poisons an open
    transaction on PostgreSQL, which this gem supports.
  • After a duplicate key, read the winning row in shared mode on MySQL, then
    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_lock adapter
    method, shaped like the existing claim_lock.

Tests

  • test/integration/enqueue_statement_count_test.rb is new. It asserts a
    steady-state enqueue issues no insert to solid_objects_instances, opens one
    transaction 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.rb gains concurrent enqueue to an actor that
    already exists, concurrent creation from callers that already wrote, and the
    retry limit that raises ActorDestroyed. The existing create race now also
    asserts exactly one instance row.

Each new test was confirmed to fail without the change. The deadlock test fails
three times out of three on main with Trilogy.

Validation

bundle exec rake test passes on all four supported combinations. Skip counts
match main exactly, so nothing became a silent skip.

Backend Before After
SQLite 658 runs, 0 failures, 24 skips 665 runs, 0 failures, 24 skips
PostgreSQL 18 658 runs, 0 failures, 16 skips 665 runs, 0 failures, 16 skips
MySQL 8.4, mysql2 658 runs, 0 failures, 32 skips 665 runs, 0 failures, 32 skips
MySQL 8.4, Trilogy 658 runs, 0 failures, 32 skips 665 runs, 0 failures, 32 skips

bundle exec rake standard rubocop rbs steep security passes. Steep reports no
type error and Brakeman reports no warning. sig/generated/ is regenerated.

Compatibility

No migration and no API change. shared_lock is additive and defaults to
nil. A host application that runs PostgreSQL at repeatable read gets a
RecordNotFound retry on a lost create race rather than a shared read, which
with_instance_retry already 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_message
and Executor#complete each open their own fenced_transaction. A 39-message
drain issues 81 FOR UPDATE statements against the instance row. Batching
would 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.lock are updated in a separate commit, and bundle exec rake
passes on the released tree. Tag v0.15.2 after this merges to publish.

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.
@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule violations remain.

Summary

This PR changes mailbox enqueueing to look up actor instances before insertion and lock existing instances by primary key, avoiding MySQL’s duplicate-key lock-upgrade deadlock while reducing steady-state SQL work. It also releases version 0.15.2.

  • Adds an adapter-specific shared lock for MySQL create-race recovery.
  • Removes the redundant second row lock from the enqueue path.
  • Adds concurrency, retry-limit, and SQL statement-count coverage.
  • Inlines the single-use create helper while preserving the required @rbs signatures.
  • Updates release metadata and documentation.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Enqueue actor message] --> B[Read instance ID by identity]
    B -->|Found| C[Lock instance by primary key]
    B -->|Missing| D[Create instance inside savepoint]
    D -->|Created| E[Return locked created instance]
    D -->|Duplicate identity| F[Read winning ID]
    F -->|MySQL| G[Use shared current read]
    F -->|Other adapters| H[Use ordinary read]
    G --> C
    H --> C
    C --> I[Deduplicate and allocate sequence]
    E --> I
Loading

Reviews (3) · Last reviewed commit: "refactor: inline the create race into th..."

Comment thread lib/solid_objects/mailbox.rb Outdated
Comment thread test/integration/enqueue_test.rb Outdated
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.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread lib/solid_objects/mailbox.rb Outdated
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.
@cardmagic

Copy link
Copy Markdown
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.
@cardmagic
cardmagic merged commit a984f84 into main Sep 21, 2026
40 checks passed
@cardmagic
cardmagic deleted the fix/enqueue-instance-lookup branch September 21, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant