@@ -60,6 +60,78 @@ def checked(effect_id:, outcome:, arguments: nil, result: nil)
6060 assert_empty actor . send ( :drain_effect_intents )
6161 end
6262
63+ test "a running effect keeps its process heartbeat fresh" do
64+ SolidObjects . configuration . process_heartbeat_interval = 0.02
65+ SolidObjects . configuration . process_alive_threshold = 0.1
66+ ExportActor . ref ( "long-handler" ) . async . start_recoverable_export
67+ worker = SolidObjects ::Worker . new
68+ worker . run_until_idle
69+ entered = Queue . new
70+ release = Queue . new
71+ heartbeats = Queue . new
72+ executing = nil
73+ registry = SolidObjects ::ProcessRegistry . new
74+ registry . define_singleton_method ( :heartbeat ) do
75+ updated = super ( )
76+ heartbeats << Thread . current if updated && executing
77+ updated
78+ end
79+ SolidObjects . register_effect ( :build_report ) do
80+ executing = Thread . current
81+ entered << true
82+ release . pop
83+ { "artifact_key" => "report.pdf" }
84+ end
85+ executor = SolidObjects ::EffectExecutor . new ( process_registry : registry )
86+ effect_thread = Thread . new do
87+ SolidObjects ::Record . connection_pool . with_connection { executor . run_once }
88+ end
89+ Timeout . timeout ( 5 ) { entered . pop }
90+ heartbeat_thread = Timeout . timeout ( 2 ) { heartbeats . pop }
91+ Timeout . timeout ( 2 ) { 7 . times { heartbeats . pop } }
92+ SolidObjects ::EffectRecoveryCoordinator . new . recover_available
93+ assert_equal "processing" , SolidObjects ::Effect . find_by! ( name : "build_report" ) . status
94+ assert_empty SolidObjects ::Message . where ( operation : "retired" )
95+ release << true
96+ assert effect_thread . value
97+ refute heartbeat_thread . alive?
98+ assert_equal "completed" , SolidObjects ::Effect . find_by! ( name : "build_report" ) . status
99+ ensure
100+ release &.push ( true )
101+ effect_thread &.join ( 5 )
102+ executor &.stop
103+ worker &.stop
104+ end
105+
106+ test "a failed handler stops its heartbeat before scheduling a retry" do
107+ SolidObjects . configuration . process_heartbeat_interval = 0.01
108+ ExportActor . ref ( "failing-handler" ) . async . start_recoverable_export
109+ worker = SolidObjects ::Worker . new
110+ worker . run_until_idle
111+ heartbeats = Queue . new
112+ registry = SolidObjects ::ProcessRegistry . new
113+ registry . define_singleton_method ( :heartbeat ) do
114+ updated = super ( )
115+ heartbeats << Thread . current if updated
116+ updated
117+ end
118+ heartbeat_thread = nil
119+ SolidObjects . register_effect ( :build_report ) do
120+ heartbeats . pop ( true ) until heartbeats . empty?
121+ heartbeat_thread = Timeout . timeout ( 2 ) { heartbeats . pop }
122+ raise "remote request failed"
123+ end
124+ executor = SolidObjects ::EffectExecutor . new ( process_registry : registry )
125+ refute executor . run_once
126+ refute_nil heartbeat_thread
127+ refute heartbeat_thread . alive?
128+ assert_equal "pending" , SolidObjects ::Effect . find_by! ( name : "build_report" ) . status
129+ assert_empty SolidObjects ::Message . where ( operation : "retired" )
130+ ensure
131+ executor &.stop
132+ worker &.stop
133+ end
134+
63135 test "a longer recovery timeout survives ordinary process cleanup" do
64136 ExportActor . ref ( "long-grace" ) . async . start_with_timeout ( timeout : 120 )
65137 worker = SolidObjects ::Worker . new
0 commit comments