Skip to content

Commit a984f84

Browse files
authored
Merge pull request #70 from cardmagic/fix/enqueue-instance-lookup
fix: lock the actor instance by primary key
2 parents 99c41e1 + 9cbd293 commit a984f84

12 files changed

Lines changed: 219 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.15.2 - 2026-09-21
4+
5+
- Find the actor instance before the insert when an enqueue starts, and lock
6+
that row by its primary key. A steady-state enqueue now writes no instance
7+
row and issues 10 statements instead of 12.
8+
- Stop the deadlock between concurrent enqueues that create the same actor
9+
inside a transaction that already wrote. MySQL keeps the shared lock of a
10+
failed insert across a savepoint rollback, so the mailbox reads the winning
11+
row in shared mode and never asks to upgrade that lock.
12+
313
## 0.15.1 - 2026-09-16
414

515
- Use the existing cleanup index when finding expired actor instances. Preserve

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.15.1)
4+
solid_objects (0.15.2)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.15.1)
387+
solid_objects (0.15.2)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎docs/roadmap.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
- Explicit actor registry, references, JSON state, and state migrations
77
- Fluent direct synchronous RPC, configured `sync`, and durable `async`
88
- Durable message history plus ready/claimed membership tables
9-
- Concurrent sequence allocation and actor creation
9+
- Concurrent sequence allocation and actor creation. An enqueue finds the
10+
instance row with an unlocked read, then locks that row by its primary key.
11+
A steady-state enqueue writes no instance row, and issues 10 statements
12+
instead of 12. Concurrent creation causes no deadlock on SQLite, PostgreSQL,
13+
or MySQL. MySQL needs a shared read after a duplicate key, because it uses
14+
repeatable read. The tests count statements, and do not measure latency.
1015
- Activation leases, renewal, unique activation tokens, generations, and
1116
fenced commits
1217
- Bounded activation passes, idle cache, hot-actor yield, and process records

‎lib/solid_objects/database_adapter.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ def claim_lock
9999
nil
100100
end
101101

102+
# @rbs () -> String?
103+
def shared_lock
104+
nil
105+
end
106+
102107
# @rbs () -> String
103108
def current_time_expression
104109
"CURRENT_TIMESTAMP"
@@ -163,6 +168,11 @@ def lock_candidates(relation)
163168
claim_lock ? relation.lock(claim_lock) : relation
164169
end
165170

171+
# @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
172+
def share_locked(relation)
173+
shared_lock ? relation.lock(shared_lock) : relation
174+
end
175+
166176
private
167177

168178
attr_reader :connection_pool, :fixed_connection

‎lib/solid_objects/database_adapters/mysql.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ def claim_lock
1515
"FOR UPDATE SKIP LOCKED"
1616
end
1717

18+
# @rbs () -> String
19+
def shared_lock
20+
"FOR SHARE"
21+
end
22+
1823
# A non-transactional engine would silently break fenced commits, so the
1924
# storage engine is verified rather than assumed.
2025
# @rbs () -> Array[String]

‎lib/solid_objects/mailbox.rb‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def enqueue_in_transaction(
5252
max_bytes: SolidObjects.configuration.max_payload_bytes
5353
)
5454
instance = find_or_create_instance(reference, actor_class)
55-
instance.lock!
5655

5756
existing = find_idempotent_message(instance, idempotency_key)
5857
if existing
@@ -108,13 +107,23 @@ def with_instance_retry
108107

109108
# @rbs (Reference, Class) -> Instance
110109
def find_or_create_instance(reference, actor_class)
111-
Instance.create_or_find_by!(
112-
actor_type: reference.actor_type,
113-
actor_id: reference.actor_id
114-
) do |instance|
115-
instance.state = {}
116-
instance.state_version = actor_class.state_version
110+
identity = { actor_type: reference.actor_type, actor_id: reference.actor_id }
111+
identifier = Instance.where(identity).pick(:id)
112+
return lock_instance!(identifier) if identifier
113+
114+
Instance.transaction(requires_new: true) do
115+
Instance.create!(**identity, state: {}, state_version: actor_class.state_version)
117116
end
117+
rescue ActiveRecord::RecordNotUnique
118+
lock_instance!(database_adapter.share_locked(Instance.where(identity)).pick(:id))
119+
end
120+
121+
# @rbs (Integer?) -> Instance
122+
def lock_instance!(identifier)
123+
instance = identifier && Instance.lock.find_by(id: identifier)
124+
return instance if instance
125+
126+
raise ActiveRecord::RecordNotFound, "actor instance disappeared while enqueueing"
118127
end
119128

120129
# @rbs (Instance, String?) -> Message?

‎lib/solid_objects/version.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.15.1"
4+
VERSION = "0.15.2"
55
end

‎sig/generated/lib/solid_objects/database_adapter.rbs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ module SolidObjects
4949
# @rbs () -> String?
5050
def claim_lock: () -> String?
5151

52+
# @rbs () -> String?
53+
def shared_lock: () -> String?
54+
5255
# @rbs () -> String
5356
def current_time_expression: () -> String
5457

@@ -70,6 +73,9 @@ module SolidObjects
7073
# @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
7174
def lock_candidates: (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
7275

76+
# @rbs (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
77+
def share_locked: (ActiveRecord::Relation[untyped]) -> ActiveRecord::Relation[untyped]
78+
7379
private
7480

7581
attr_reader connection_pool: untyped

‎sig/generated/lib/solid_objects/database_adapters/mysql.rbs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ module SolidObjects
1111
# @rbs () -> String
1212
def claim_lock: () -> String
1313

14+
# @rbs () -> String
15+
def shared_lock: () -> String
16+
1417
# A non-transactional engine would silently break fenced commits, so the
1518
# storage engine is verified rather than assumed.
1619
# @rbs () -> Array[String]

‎sig/generated/lib/solid_objects/mailbox.rbs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ module SolidObjects
2828
# @rbs (Reference, Class) -> Instance
2929
def find_or_create_instance: (Reference, Class) -> Instance
3030

31+
# @rbs (Integer?) -> Instance
32+
def lock_instance!: (Integer?) -> Instance
33+
3134
# @rbs (Instance, String?) -> Message?
3235
def find_idempotent_message: (Instance, String?) -> Message?
3336

0 commit comments

Comments
 (0)