@@ -286,6 +286,57 @@ def checked(effect_id:, outcome:, arguments: nil, result: nil)
286286 worker &.stop
287287 end
288288
289+ test "one remaining mailbox slot cannot partially commit retirement" do
290+ worker , effect_executor , effect = processing_export ( "full-mailbox" )
291+ original_limit = SolidObjects . configuration . max_mailbox_length
292+ SolidObjects . configuration . max_mailbox_length = 1
293+ assert_raises ( SolidObjects ::MailboxFull ) do
294+ SolidObjects . database_adapter . transaction do
295+ instance = SolidObjects ::Instance . lock . find ( effect . instance_id )
296+ SolidObjects ::EffectRecoveryCoordinator . new . check ( instance :, intents : [ SolidObjects ::Actor ::EffectRecoveryIntent . new ( effect_id : effect . effect_id , request_id : "full" ) ] )
297+ end
298+ end
299+ assert_equal "processing" , effect . reload . status
300+ assert_nil SolidObjects ::EffectRecovery . find ( effect . effect_id ) . retired_at
301+ assert_empty SolidObjects ::Message . where ( operation : [ "retired" , "checked" ] )
302+ ensure
303+ SolidObjects . configuration . max_mailbox_length = original_limit if original_limit
304+ effect_executor &.stop
305+ worker &.stop
306+ end
307+
308+ test "runtime floor changes and missing owners are rechecked for existing effects" do
309+ worker , effect_executor , effect = processing_export ( "runtime-floor" )
310+ SolidObjects ::EffectRecovery . find ( effect . effect_id ) . update! ( recovery_timeout : 1 )
311+ SolidObjects ::Process . find ( effect . claimed_by ) . update! ( last_heartbeat_at : SolidObjects . database_adapter . database_clock_now - 70 )
312+ SolidObjects . configuration . process_alive_threshold = 120
313+ SolidObjects ::EffectRecoveryCoordinator . new . recover_available
314+ assert_equal "processing" , effect . reload . status
315+ assert_empty SolidObjects ::Message . where ( operation : "retired" )
316+ effect . update! ( claimed_by : nil )
317+ SolidObjects ::EffectRecoveryCoordinator . new . recover_available
318+ assert_equal 1 , SolidObjects ::Message . where ( operation : "retired" ) . count
319+ ensure
320+ effect_executor &.stop
321+ worker &.stop
322+ end
323+
324+ test "database time lookup failure rolls back without an abandonment outcome" do
325+ worker , effect_executor , effect = processing_export ( "lookup-error" )
326+ adapter = SolidObjects . database_adapter
327+ adapter . define_singleton_method ( :database_clock_now ) do
328+ SolidObjects ::Record . connection . select_value ( "SELECT absent_recovery_column FROM #{ SolidObjects . table_name ( :processes ) } " )
329+ end
330+ assert_raises ( ActiveRecord ::StatementInvalid ) { SolidObjects ::EffectRecoveryCoordinator . new . recover_available }
331+ assert_equal "processing" , effect . reload . status
332+ assert_nil SolidObjects ::EffectRecovery . find ( effect . effect_id ) . retired_at
333+ assert_empty SolidObjects ::Message . where ( operation : [ "retired" , "checked" ] )
334+ ensure
335+ adapter &.singleton_class &.remove_method ( :database_clock_now )
336+ effect_executor &.stop
337+ worker &.stop
338+ end
339+
289340 test "a changed claimant is rechecked after the effect lock becomes available" do
290341 skip "PostgreSQL lock observation" unless database_family == :postgresql
291342
@@ -310,6 +361,176 @@ def checked(effect_id:, outcome:, arguments: nil, result: nil)
310361 worker &.stop
311362 end
312363
364+ test "pending work does not wait on a recovery candidate within its extended grace" do
365+ skip "PostgreSQL independent claims" unless database_family == :postgresql
366+
367+ worker , effect_executor , effect = processing_export ( "fresh-candidate" )
368+ SolidObjects ::EffectRecovery . find ( effect . effect_id ) . update! ( recovery_timeout : 120 )
369+ SolidObjects ::Process . find ( effect . claimed_by ) . update! ( last_heartbeat_at : SolidObjects . database_adapter . database_clock_now - 75 )
370+ ExportActor . ref ( "other" ) . async . start_export
371+ worker . run_until_idle
372+ claimant = SolidObjects ::EffectExecutor . new
373+ results = Queue . new
374+ claim_thread = nil
375+ SolidObjects . database_adapter . transaction do
376+ SolidObjects ::Instance . lock . find ( effect . instance_id )
377+ claim_thread = Thread . new do
378+ SolidObjects ::Record . connection_pool . with_connection do
379+ results << claimant . send ( :claim_next )
380+ end
381+ end
382+ selected = Timeout . timeout ( 2 ) { results . pop }
383+ assert_equal "other" , selected . instance . actor_id
384+ end
385+ ensure
386+ claim_thread &.join ( 5 )
387+ claimant &.stop
388+ effect_executor &.stop
389+ worker &.stop
390+ end
391+
392+ test "recovery notification survives a process crash after retirement" do
393+ worker , effect_executor , effect = processing_export ( "crash" )
394+ worker . stop
395+ configuration = SolidObjects ::Record . connection_db_config . configuration_hash
396+ script = <<~RUBY
397+ require "solid_objects"
398+ ActiveRecord::Base.establish_connection(JSON.parse(ENV.fetch("RECOVERY_DATABASE_CONFIGURATION")))
399+ %w[record process instance message ready_message claimed_message reminder effect effect_recovery broadcast dead_letter].each do |model|
400+ require File.expand_path("app/models/solid_objects/\# {model}")
401+ end
402+ class RecoveryExport < SolidObjects::Actor
403+ actor_type "effect-recovery-export"
404+ def retired(effect_id:, arguments:, outcome:)
405+ end
406+ end
407+ SolidObjects::EffectRecoveryCoordinator.new.recover_available
408+ ::Process.kill("KILL", ::Process.pid)
409+ RUBY
410+ process_id = ::Process . spawn ( { "RECOVERY_DATABASE_CONFIGURATION" => JSON . generate ( configuration ) } , Gem . ruby , "-Ilib" , "-e" , script )
411+ _ , status = ::Process . wait2 ( process_id )
412+ assert status . signaled?
413+ assert_equal Signal . list . fetch ( "KILL" ) , status . termsig
414+ assert_equal 1 , SolidObjects ::Message . where ( operation : "retired" ) . count
415+ assert_empty effect . instance . reload . state . fetch ( "notifications" )
416+ worker = SolidObjects ::Worker . new
417+ worker . run_until_idle
418+ assert_equal 1 , effect . instance . reload . state . fetch ( "notifications" ) . length
419+ assert_nil effect_executor . send ( :claim_next )
420+ ensure
421+ effect_executor &.stop
422+ worker &.stop
423+ end
424+
425+ test "a pending claimant wins before the explicit check and is rechecked" do
426+ skip "PostgreSQL independent claims" unless database_family == :postgresql
427+
428+ ExportActor . ref ( "claim-first" ) . async . start_checked_export
429+ worker = SolidObjects ::Worker . new
430+ worker . run_until_idle
431+ effect = SolidObjects ::Effect . find_by! ( name : "build_report" )
432+ claimant = SolidObjects ::EffectExecutor . new
433+ adapter = SolidObjects . database_adapter
434+ original_lock = adapter . method ( :lock_candidates )
435+ locked = Queue . new
436+ release = Queue . new
437+ results = Queue . new
438+ origin_locked = Queue . new
439+ adapter . define_singleton_method ( :lock_candidates ) do |scope |
440+ relation = original_lock . call ( scope ) . load
441+ locked << true
442+ release . pop
443+ relation
444+ end
445+ claim_thread = Thread . new do
446+ SolidObjects ::Record . connection_pool . with_connection { results << claimant . send ( :claim_next ) }
447+ end
448+ Timeout . timeout ( 5 ) { locked . pop }
449+ check_thread = Thread . new do
450+ SolidObjects ::Record . connection_pool . with_connection do
451+ SolidObjects . database_adapter . transaction do
452+ instance = SolidObjects ::Instance . lock . find ( effect . instance_id )
453+ origin_locked << true
454+ SolidObjects ::EffectRecoveryCoordinator . new . check ( instance :, intents : [ SolidObjects ::Actor ::EffectRecoveryIntent . new ( effect_id : effect . effect_id , request_id : "claim-first" ) ] )
455+ end
456+ end
457+ end
458+ Timeout . timeout ( 5 ) { origin_locked . pop }
459+ release << true
460+ assert_equal effect . id , Timeout . timeout ( 5 ) { results . pop } . id
461+ check_thread . join ( 5 )
462+ assert_equal "deferred" , SolidObjects ::Message . find_by! ( operation : "checked" ) . arguments . fetch ( "outcome" )
463+ assert_empty SolidObjects ::Message . where ( operation : "retired" )
464+ ensure
465+ release &.push ( true )
466+ claim_thread &.join ( 5 )
467+ check_thread &.join ( 5 )
468+ adapter &.singleton_class &.remove_method ( :lock_candidates )
469+ claimant &.stop
470+ worker &.stop
471+ end
472+
473+ test "an explicit check can win and leave pending work for the claimant" do
474+ skip "PostgreSQL independent claims" unless database_family == :postgresql
475+
476+ ExportActor . ref ( "check-first" ) . async . start_checked_export
477+ worker = SolidObjects ::Worker . new
478+ worker . run_until_idle
479+ effect = SolidObjects ::Effect . find_by! ( name : "build_report" )
480+ claimant = SolidObjects ::EffectExecutor . new
481+ results = Queue . new
482+ claim_thread = nil
483+ SolidObjects . database_adapter . transaction do
484+ instance = SolidObjects ::Instance . lock . find ( effect . instance_id )
485+ SolidObjects ::EffectRecoveryCoordinator . new . check ( instance :, intents : [ SolidObjects ::Actor ::EffectRecoveryIntent . new ( effect_id : effect . effect_id , request_id : "check-first" ) ] )
486+ claim_thread = Thread . new do
487+ SolidObjects ::Record . connection_pool . with_connection { results << claimant . send ( :claim_next ) }
488+ end
489+ assert_nil Timeout . timeout ( 5 ) { results . pop }
490+ end
491+ assert_equal effect . id , claimant . send ( :claim_next ) . id
492+ assert_equal "pending" , SolidObjects ::Message . find_by! ( operation : "checked" ) . arguments . fetch ( "outcome" )
493+ assert_empty SolidObjects ::Message . where ( operation : "retired" )
494+ ensure
495+ claim_thread &.join ( 5 )
496+ claimant &.stop
497+ worker &.stop
498+ end
499+
500+ test "concurrent explicit checks and a replay share one retirement" do
501+ skip "PostgreSQL independent checks" unless database_family == :postgresql
502+
503+ worker , effect_executor , effect = processing_export ( "explicit-race" )
504+ process_ids = Queue . new
505+ check = -> ( request_id ) do
506+ SolidObjects . database_adapter . transaction do
507+ instance = SolidObjects ::Instance . lock . find ( effect . instance_id )
508+ SolidObjects ::EffectRecoveryCoordinator . new . check ( instance :, intents : [ SolidObjects ::Actor ::EffectRecoveryIntent . new ( effect_id : effect . effect_id , request_id :) ] )
509+ end
510+ end
511+ threads = [ ]
512+ SolidObjects . database_adapter . transaction do
513+ SolidObjects ::Instance . lock . find ( effect . instance_id )
514+ %w[ one two ] . each do |request_id |
515+ threads << Thread . new do
516+ SolidObjects ::Record . connection_pool . with_connection do |connection |
517+ process_ids << connection . select_value ( "SELECT pg_backend_pid()" ) . to_i
518+ check . call ( request_id )
519+ end
520+ end
521+ end
522+ 2 . times { wait_for_blocked_process ( Timeout . timeout ( 5 ) { process_ids . pop } ) }
523+ end
524+ threads . each { |thread | thread . join ( 5 ) }
525+ check . call ( "one" )
526+ assert_equal 1 , SolidObjects ::Message . where ( operation : "retired" ) . count
527+ assert_equal %w[ retired already_retired ] , SolidObjects ::Message . where ( operation : "checked" ) . order ( :sequence ) . map { |message | message . arguments . fetch ( "outcome" ) }
528+ ensure
529+ threads &.each { |thread | thread . join ( 5 ) }
530+ effect_executor &.stop
531+ worker &.stop
532+ end
533+
313534 private
314535
315536 def processing_export ( actor_id )
0 commit comments