Skip to content

Commit 888d1e0

Browse files
committed
fix: find recovery candidates through effects
sqlite_stat1 records only the average row count for each effect status. When most effects are complete, SQLite estimated that status = 'processing' matched most of the effects table. It then read every effect_recoveries row in primary key order to skip the ORDER BY sort, and looked up each effect. A production app saw 5.75 ms per call on SQLite against 0.68 ms on MySQL. This is not a missing index. Only a recovery retirement writes retired_at, so a recovery row keeps it empty after a normal completion, and the recovery filter matches almost every row. CROSS JOIN alone does not fix it: when every effect has one status, SQLite scans the whole effects table. unlikely() is too weak at 500,000 effects. On SQLite the status test now carries likelihood(..., 0.000001), which overrides the statistics estimate for that term, so the plan searches idx_so_effects_poll first. The PostgreSQL and MySQL SQL does not change.
1 parent 32d2ea2 commit 888d1e0

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

‎lib/solid_objects/effect_recovery_coordinator.rb‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,22 @@ def recovery_candidates
6363
effects = Effect.table_name
6464
owners = Process.table_name
6565
bindings = EffectRecovery.table_name
66-
heartbeat = case DatabaseAdapter.family(Record.connection)
66+
family = DatabaseAdapter.family(Record.connection)
67+
heartbeat = case family
6768
when :postgresql then "EXTRACT(EPOCH FROM #{owners}.last_heartbeat_at)"
6869
when :mysql then "UNIX_TIMESTAMP(#{owners}.last_heartbeat_at)"
6970
else "CAST(STRFTIME('%s', #{owners}.last_heartbeat_at) AS REAL)"
7071
end
72+
processing = case family
73+
when :postgresql, :mysql then "#{effects}.status = ?"
74+
else "likelihood(#{effects}.status = ?, 0.000001)"
75+
end
7176
now = SolidObjects.database_adapter.database_clock_now.to_f
7277
threshold = SolidObjects.configuration.process_alive_threshold
7378
EffectRecovery.joins("INNER JOIN #{effects} ON #{effects}.effect_id = #{bindings}.effect_id")
7479
.joins("LEFT JOIN #{owners} ON #{owners}.id = #{effects}.claimed_by")
7580
.where(retired_at: nil).where.not(recovery_operation: nil)
76-
.where("#{effects}.status = ?", "processing")
81+
.where(processing, "processing")
7782
.where("#{owners}.id IS NULL OR #{heartbeat} <= ? - CASE WHEN #{bindings}.recovery_timeout > ? THEN #{bindings}.recovery_timeout ELSE ? END", now, threshold, threshold)
7883
.order(:effect_id).limit(SolidObjects.configuration.claim_scan_limit)
7984
end
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
require "database_test_helper"
4+
5+
class EffectRecoveryCandidatesTest < ActiveSupport::TestCase
6+
class CandidateActor < SolidObjects::Actor
7+
actor_type "effect-recovery-candidates"
8+
9+
def run
10+
end
11+
end
12+
13+
setup do
14+
@message = SolidObjects::Message.find(CandidateActor.ref("one").async.run.id)
15+
end
16+
17+
test "recovery candidates are abandoned processing effects with a recovery operation in effect order" do
18+
now = SolidObjects.database_adapter.database_clock_now
19+
threshold = SolidObjects.configuration.process_alive_threshold
20+
live_owner = create_process(last_heartbeat_at: now)
21+
stale_owner = create_process(last_heartbeat_at: now - (threshold * 3))
22+
unowned = create_effect("00000000-0000-4000-8000-000000000009", status: "processing")
23+
create_effect("00000000-0000-4000-8000-000000000001", status: "processing", owner: live_owner)
24+
abandoned = create_effect("00000000-0000-4000-8000-000000000002", status: "processing", owner: stale_owner)
25+
create_effect("00000000-0000-4000-8000-000000000003", status: "processing", owner: stale_owner, recovery_timeout: threshold * 10)
26+
create_effect("00000000-0000-4000-8000-000000000004", status: "processing", retired_at: now)
27+
create_effect("00000000-0000-4000-8000-000000000005", status: "processing", recovery_operation: nil)
28+
create_effect("00000000-0000-4000-8000-000000000006", status: "pending")
29+
create_effect("00000000-0000-4000-8000-000000000007", status: "completed")
30+
31+
assert_equal [ abandoned, unowned ], recovery_candidates.map(&:effect_id)
32+
end
33+
34+
test "recovery candidates stop at the claim scan limit" do
35+
SolidObjects.configuration.claim_scan_limit = 1
36+
create_effect("00000000-0000-4000-8000-000000000002", status: "processing")
37+
first = create_effect("00000000-0000-4000-8000-000000000001", status: "processing")
38+
39+
assert_equal [ first ], recovery_candidates.map(&:effect_id)
40+
end
41+
42+
test "SQLite finds recovery candidates through processing effects when most effects are complete" do
43+
skip "requires a SQLite query plan" unless database_family == :sqlite
44+
45+
now = Time.current
46+
effect_ids = Array.new(3_000) { SecureRandom.uuid }
47+
SolidObjects::Effect.insert_all!(effect_ids.map { |effect_id|
48+
{ message_id: @message.id, instance_id: @message.instance_id, effect_id:, name: "work", arguments: {},
49+
status: "completed", max_attempts: 3, available_at: now, completed_at: now, created_at: now, updated_at: now }
50+
})
51+
SolidObjects::EffectRecovery.insert_all!(effect_ids.last(300).map { |effect_id|
52+
{ effect_id:, instance_id: @message.instance_id, recovery_operation: "recover", status_operation: "status",
53+
created_at: now, updated_at: now }
54+
})
55+
connection = SolidObjects::Record.connection
56+
connection.execute("ANALYZE")
57+
poll_statistics = connection.select_value("SELECT stat FROM sqlite_stat1 WHERE idx = 'idx_so_effects_poll'")
58+
59+
assert_equal 3_000, poll_statistics.split[1].to_i
60+
61+
plan = connection.select_all("EXPLAIN QUERY PLAN #{recovery_candidates.to_sql}").map { |row| row["detail"] }
62+
63+
assert_match(/\ASEARCH #{SolidObjects::Effect.table_name} USING INDEX idx_so_effects_poll \(status=\?\)/, plan.first, plan.join("\n"))
64+
assert plan.none? { |step| step.start_with?("SCAN ") }, plan.join("\n")
65+
ensure
66+
connection&.execute("DELETE FROM sqlite_stat1")
67+
connection&.execute("ANALYZE sqlite_schema")
68+
end
69+
70+
private
71+
72+
def recovery_candidates
73+
SolidObjects::EffectRecoveryCoordinator.new.send(:recovery_candidates)
74+
end
75+
76+
def create_effect(effect_id, status:, owner: nil, recovery_operation: "recover", recovery_timeout: nil, retired_at: nil)
77+
SolidObjects::Effect.create!(
78+
message: @message,
79+
instance: @message.instance,
80+
effect_id:,
81+
name: "work",
82+
arguments: {},
83+
status:,
84+
max_attempts: 3,
85+
available_at: Time.current,
86+
claimed_by: owner&.id,
87+
claimed_at: owner && Time.current
88+
)
89+
SolidObjects::EffectRecovery.create!(
90+
effect_id:,
91+
instance: @message.instance,
92+
recovery_operation:,
93+
status_operation: "status",
94+
recovery_timeout:,
95+
retired_at:
96+
)
97+
effect_id
98+
end
99+
100+
def create_process(last_heartbeat_at:)
101+
SolidObjects::Process.create!(
102+
id: SecureRandom.uuid,
103+
kind: "effect",
104+
hostname: "test-host",
105+
pid: ::Process.pid,
106+
started_at: last_heartbeat_at,
107+
last_heartbeat_at:,
108+
metadata: {}
109+
)
110+
end
111+
end

0 commit comments

Comments
 (0)