Skip to content

Commit 32d2ea2

Browse files
committed
fix: start SQLite claimed scans from the queue
ActivationManager#claimed_instance_ids joins claimed messages to instances, and its WHERE clause has conditions on instance columns only. The claimed-messages table is a short queue, so it is often empty when ANALYZE or PRAGMA optimize runs. sqlite_stat1 then has no row for it, and SQLite assumes that the table is large. The planner used the analyzed instances table as the outer loop and scanned every instance on each worker poll. A production app saw 36.7 to 74 ms per call on a SQLite copy of 71,863 instances and no claimed messages, against 0.67 ms on MySQL. SQLite keeps CROSS JOIN as a fixed join order, so the query now starts from the claimed messages without help from statistics. PostgreSQL and MySQL treat CROSS JOIN with an equality in WHERE as an inner join. Their EXPLAIN ANALYZE plans and costs did not change, with 0 and with 200 claimed rows. The ids and their order do not change. A row in sqlite_stat1 that marks the table as small also fixes the plan, but the next ANALYZE of an empty queue deletes it.
1 parent c18b82b commit 32d2ea2

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

‎lib/solid_objects/activation_manager.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ def ready_instance_ids(now)
8686
# @rbs (Time) -> Array[Integer]
8787
def claimed_instance_ids(now)
8888
ClaimedMessage
89-
.joins(:instance)
89+
.joins("CROSS JOIN #{Instance.table_name}")
90+
.where("#{Instance.table_name}.id = #{ClaimedMessage.table_name}.instance_id")
9091
.where("#{Instance.table_name}.paused_at IS NULL")
9192
.where(available_lease_sql, now)
9293
.group(:instance_id)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# frozen_string_literal: true
2+
3+
require "database_test_helper"
4+
5+
class ActivationCandidatesTest < ActiveSupport::TestCase
6+
class CandidateActor < SolidObjects::Actor
7+
actor_type "activation-candidates"
8+
9+
def run
10+
end
11+
end
12+
13+
test "claimed candidates skip paused and live leases in claim order" do
14+
now = SolidObjects.database_adapter.database_now
15+
owner = create_process
16+
expired = claim_message("expired", claimed_at: now - 40.seconds, owner:, lease_expires_at: now - 1.second)
17+
unleased = claim_message("unleased", claimed_at: now - 30.seconds)
18+
first_tie = claim_message("first-tie", claimed_at: now - 20.seconds)
19+
second_tie = claim_message("second-tie", claimed_at: now - 20.seconds)
20+
unexpiring = claim_message("unexpiring", claimed_at: now - 10.seconds, owner:)
21+
claim_message("paused", claimed_at: now - 50.seconds, paused_at: now - 1.minute)
22+
claim_message("leased", claimed_at: now - 60.seconds, owner:, lease_expires_at: now + 1.minute)
23+
24+
assert_equal [ expired, unleased, first_tie, second_tie, unexpiring ], claimed_instance_ids(now)
25+
end
26+
27+
test "claimed candidates stop at the claim scan limit" do
28+
now = SolidObjects.database_adapter.database_now
29+
SolidObjects.configuration.claim_scan_limit = 2
30+
oldest = claim_message("oldest", claimed_at: now - 30.seconds)
31+
older = claim_message("older", claimed_at: now - 20.seconds)
32+
claim_message("newest", claimed_at: now - 10.seconds)
33+
34+
assert_equal [ oldest, older ], claimed_instance_ids(now)
35+
end
36+
37+
test "SQLite reads claimed candidates from the claimed messages when they have no statistics" do
38+
skip "requires a SQLite query plan" unless database_family == :sqlite
39+
40+
now = SolidObjects.database_adapter.database_now
41+
SolidObjects::Instance.insert_all!(Array.new(3_000) { |index|
42+
{ actor_type: "activation-candidates", actor_id: "idle-#{index}", state: {}, created_at: now, updated_at: now }
43+
})
44+
connection = SolidObjects::Record.connection
45+
connection.execute("ANALYZE")
46+
analyzed_tables = connection.select_values("SELECT DISTINCT tbl FROM sqlite_stat1")
47+
48+
assert_includes analyzed_tables, SolidObjects::Instance.table_name
49+
refute_includes analyzed_tables, SolidObjects::ClaimedMessage.table_name
50+
51+
plan = sqlite_query_plan(connection, SolidObjects::ClaimedMessage.table_name) { claimed_instance_ids(now) }
52+
53+
assert_match(/\A(SCAN|SEARCH) #{SolidObjects::ClaimedMessage.table_name}\b/, plan.first, plan.join("\n"))
54+
assert plan.none? { |step| step.start_with?("SCAN #{SolidObjects::Instance.table_name}") }, plan.join("\n")
55+
ensure
56+
connection&.execute("DELETE FROM sqlite_stat1")
57+
connection&.execute("ANALYZE sqlite_schema")
58+
end
59+
60+
private
61+
62+
def claimed_instance_ids(now)
63+
SolidObjects::ActivationManager.new(owner_id: SecureRandom.uuid).send(:claimed_instance_ids, now)
64+
end
65+
66+
def claim_message(actor_id, claimed_at:, owner: nil, lease_expires_at: nil, paused_at: nil)
67+
message = SolidObjects::Message.find(CandidateActor.ref(actor_id).async.run.id)
68+
SolidObjects::ReadyMessage.where(message:).delete_all
69+
message.instance.update!(
70+
activation_owner_id: owner&.id,
71+
activation_token: owner && SecureRandom.uuid,
72+
activation_expires_at: lease_expires_at,
73+
paused_at:
74+
)
75+
SolidObjects::ClaimedMessage.create!(message:, instance: message.instance, activation_generation: 1, claimed_at:)
76+
message.instance_id
77+
end
78+
79+
def create_process
80+
SolidObjects::Process.create!(
81+
id: SecureRandom.uuid,
82+
kind: "worker",
83+
hostname: "test-host",
84+
pid: ::Process.pid,
85+
started_at: Time.current,
86+
last_heartbeat_at: Time.current,
87+
metadata: {}
88+
)
89+
end
90+
91+
def sqlite_query_plan(connection, table_name)
92+
statements = []
93+
subscriber = ->(*arguments) { statements << arguments.last }
94+
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") { yield }
95+
statement = statements.find { |payload| payload[:sql].match?(/\ASELECT .* FROM "#{table_name}"/) }
96+
assert statement, "the #{table_name} query was not captured"
97+
98+
connection.select_all("EXPLAIN QUERY PLAN #{statement[:sql]}", "SQL", statement[:binds]).map { |row| row["detail"] }
99+
end
100+
end

0 commit comments

Comments
 (0)