Skip to content

Commit 63448d4

Browse files
authored
Merge pull request #60 from cardmagic/fix/polling-query-performance
Fix broadcast polling query plans
2 parents dd72e6e + ac0df54 commit 63448d4

7 files changed

Lines changed: 150 additions & 8 deletions

File tree

CHANGELOG.md

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

3+
## 0.14.5 - 2026-09-03
4+
5+
- Split broadcast claiming into separate pending and stale-processing probes,
6+
then choose the oldest locked candidate across both. The old `OR` query made
7+
MySQL, PostgreSQL, and SQLite collect and sort eligible rows before applying
8+
`LIMIT 1`; each probe now follows the existing
9+
`(status, available_at, id)` index while preserving delivery order, recovery,
10+
and concurrent claimant safety. No migration or new index is required.
11+
312
## 0.14.4 - 2026-08-30
413

514
- Reuse the encoding the after image already built. `State#to_h` copies the

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.14.4)
4+
solid_objects (0.14.5)
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.14.4)
387+
solid_objects (0.14.5)
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

lib/solid_objects/broadcast_executor.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,15 @@ def claim_next
112112
database_adapter.transaction do
113113
now = database_adapter.database_now
114114
stale_at = now - SolidObjects.configuration.process_alive_threshold
115-
relation = Broadcast
116-
.where(status: "pending", available_at: ..now)
117-
.or(Broadcast.where(status: "processing", claimed_at: ..stale_at))
118-
.order(:available_at, :id)
119-
broadcast = database_adapter.lock_candidates(relation).first
115+
pending_broadcast = claim_candidate(
116+
Broadcast.where(status: "pending", available_at: ..now)
117+
)
118+
stale_broadcast = claim_candidate(
119+
Broadcast.where(status: "processing", claimed_at: ..stale_at)
120+
)
121+
broadcast = [ pending_broadcast, stale_broadcast ]
122+
.compact
123+
.min_by { |candidate| [ candidate.available_at, candidate.id ] }
120124
next unless broadcast
121125

122126
broadcast.update!(
@@ -129,6 +133,11 @@ def claim_next
129133
end
130134
end
131135

136+
# @rbs (ActiveRecord::Relation[Broadcast]) -> Broadcast?
137+
def claim_candidate(relation)
138+
database_adapter.lock_candidates(relation.order(:available_at, :id)).first
139+
end
140+
132141
# @rbs () -> Proc | ActionCableBroadcastAdapter
133142
def broadcast_adapter
134143
SolidObjects.configuration.broadcast_adapter ||

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.14.4"
4+
VERSION = "0.14.5"
55
end

sig/generated/lib/solid_objects/broadcast_executor.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ module SolidObjects
4747
# @rbs () -> Broadcast?
4848
def claim_next: () -> Broadcast?
4949

50+
# @rbs (ActiveRecord::Relation[Broadcast]) -> Broadcast?
51+
def claim_candidate: (ActiveRecord::Relation[Broadcast]) -> Broadcast?
52+
5053
# @rbs () -> Proc | ActionCableBroadcastAdapter
5154
def broadcast_adapter: () -> Proc
5255

test/integration/broadcasts_test.rb

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,111 @@ def reveal(secret:)
149149
broadcast_executor&.stop
150150
worker&.stop
151151
end
152+
153+
test "recovers the oldest broadcast across pending and stale work" do
154+
pending_reference = PublicCounterActor.ref("pending").async.increment
155+
stale_reference = PublicCounterActor.ref("stale").async.increment
156+
worker = SolidObjects::Worker.new
157+
worker.run_until_idle
158+
stale_process_registry = SolidObjects::ProcessRegistry.new
159+
stale_process = stale_process_registry.register(kind: "broadcast")
160+
now = SolidObjects.database_adapter.database_now
161+
pending_broadcast = SolidObjects::Broadcast.find_by!(message_id: pending_reference.id)
162+
pending_broadcast.update!(available_at: now - 1.minute)
163+
stale_broadcast = SolidObjects::Broadcast.find_by!(message_id: stale_reference.id)
164+
stale_broadcast.update!(
165+
status: "processing",
166+
available_at: now - 2.minutes,
167+
claimed_by: stale_process.id,
168+
claimed_at: now - SolidObjects.configuration.process_alive_threshold - 1.second
169+
)
170+
delivered = Queue.new
171+
SolidObjects.configuration.broadcast_adapter = ->(broadcast) { delivered << broadcast.id }
172+
broadcast_executor = SolidObjects::BroadcastExecutor.new
173+
174+
assert broadcast_executor.run_once
175+
176+
assert_equal stale_broadcast.id, delivered.pop
177+
assert_equal "delivered", stale_broadcast.reload.status
178+
assert_equal "pending", pending_broadcast.reload.status
179+
ensure
180+
broadcast_executor&.stop
181+
stale_process_registry&.stop
182+
worker&.stop
183+
end
184+
185+
test "concurrent executors claim different broadcasts" do
186+
pending_reference = PublicCounterActor.ref("pending").async.increment
187+
stale_reference = PublicCounterActor.ref("stale").async.increment
188+
worker = SolidObjects::Worker.new
189+
worker.run_until_idle
190+
stale_process_registry = SolidObjects::ProcessRegistry.new
191+
stale_process = stale_process_registry.register(kind: "broadcast")
192+
now = SolidObjects.database_adapter.database_now
193+
pending_broadcast = SolidObjects::Broadcast.find_by!(message_id: pending_reference.id)
194+
pending_broadcast.update!(available_at: now - 1.minute)
195+
stale_broadcast = SolidObjects::Broadcast.find_by!(message_id: stale_reference.id)
196+
stale_broadcast.update!(
197+
status: "processing",
198+
available_at: now - 2.minutes,
199+
claimed_by: stale_process.id,
200+
claimed_at: now - SolidObjects.configuration.process_alive_threshold - 1.second
201+
)
202+
claims = Queue.new
203+
release = Queue.new
204+
SolidObjects.configuration.broadcast_adapter = lambda do |broadcast|
205+
claims << broadcast.id
206+
release.pop
207+
end
208+
executor_a = SolidObjects::BroadcastExecutor.new
209+
executor_b = SolidObjects::BroadcastExecutor.new
210+
211+
thread_a = Thread.new { executor_a.run_once }
212+
assert_equal stale_broadcast.id, Timeout.timeout(5) { claims.pop }
213+
thread_b = Thread.new { executor_b.run_once }
214+
assert_equal pending_broadcast.id, Timeout.timeout(5) { claims.pop }
215+
2.times { release << true }
216+
217+
assert thread_a.value
218+
assert thread_b.value
219+
assert_equal %w[delivered delivered], SolidObjects::Broadcast.order(:id).pluck(:status)
220+
ensure
221+
2.times { release << true } if release
222+
thread_a&.join(2)
223+
thread_b&.join(2)
224+
executor_a&.stop
225+
executor_b&.stop
226+
stale_process_registry&.stop
227+
worker&.stop
228+
end
229+
230+
test "polls pending and stale broadcasts separately" do
231+
PublicCounterActor.ref("one").async.increment
232+
worker = SolidObjects::Worker.new
233+
worker.run_until_idle
234+
SolidObjects.configuration.broadcast_adapter = ->(broadcast) { broadcast }
235+
broadcast_executor = SolidObjects::BroadcastExecutor.new
236+
polling_queries = []
237+
subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
238+
query = event.payload.fetch(:sql).squish
239+
if query.match?(/SELECT .* FROM ["`]solid_objects_broadcasts["`]/) &&
240+
query.match?(/ORDER BY .*available_at.*id.*LIMIT/i)
241+
polling_queries << query
242+
end
243+
end
244+
245+
assert broadcast_executor.run_once
246+
247+
assert_equal 2, polling_queries.length
248+
assert polling_queries.one? { |query| !query.include?("claimed_at") }
249+
assert polling_queries.one? { |query| query.include?("claimed_at") }
250+
polling_queries.each { |query| refute_match(/\sOR\s/i, query) }
251+
if database_family != :sqlite
252+
polling_queries.each { |query| assert_match(/FOR UPDATE SKIP LOCKED\z/i, query) }
253+
end
254+
ensure
255+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
256+
broadcast_executor&.stop
257+
worker&.stop
258+
end
152259
end

test/models/schema_constraints_test.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ class SchemaConstraintsTest < ActiveSupport::TestCase
4848
assert indexes.all? { |index| index.where.nil? }
4949
end
5050

51+
test "indexes each polling query in delivery order" do
52+
expected_indexes = {
53+
"solid_objects_effects" => %w[status available_at id],
54+
"solid_objects_broadcasts" => %w[status available_at id],
55+
"solid_objects_reminders" => %w[status next_run_at id]
56+
}
57+
58+
expected_indexes.each do |table, columns|
59+
indexes = ActiveRecord::Base.connection.indexes(table).map(&:columns)
60+
61+
assert_includes indexes, columns
62+
end
63+
end
64+
5165
test "links every runtime claim owner to the process registry" do
5266
expected_claim_foreign_keys = {
5367
"solid_objects_claimed_messages" => "process_id",

0 commit comments

Comments
 (0)