Skip to content

Commit c75f6c3

Browse files
committed
fix: split broadcast polling queries
The pending and stale predicates previously fed one OR query, forcing eligible rows through a sort before LIMIT 1. Lock one ordered candidate from each state and choose the earlier row so each probe follows the existing polling index without changing delivery order or claimant safety.
1 parent dd72e6e commit c75f6c3

4 files changed

Lines changed: 138 additions & 5 deletions

File tree

‎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 ||

‎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)