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