2121use DataMachine \Core \Database \Agents \Agents ;
2222use DataMachine \Core \Database \Jobs \Jobs ;
2323use DataMachine \Core \DirectJobEnqueuer ;
24+ use DataMachine \Core \DirectOperationRecoveryPolicy ;
2425use DataMachine \Core \JobRetryPolicy ;
2526use DataMachine \Core \JobStatus ;
2627use WP_UnitTestCase ;
@@ -291,7 +292,7 @@ public function test_enqueue_failure_is_reclaimable_and_concurrent_calls_schedul
291292 )
292293 );
293294
294- $ failing = new DirectJobEnqueuer ( $ jobs , static fn () => false , static fn () => 0 );
295+ $ failing = new DirectJobEnqueuer ( $ jobs , static fn () => false , static fn () => 0 , static fn () => false );
295296 $ this ->assertFalse ( $ failing ->enqueue ( $ job_id , 'ephemeral_step_0 ' )['success ' ] );
296297 $ this ->assertSame ( 'enqueue_failed ' , $ jobs ->get_job ( $ job_id )['operation_state ' ] );
297298
@@ -306,7 +307,8 @@ static function () use ( &$schedule_count, &$scheduled_id ) {
306307 },
307308 static function () use ( &$ scheduled_id ) {
308309 return $ scheduled_id ;
309- }
310+ },
311+ static fn () => true
310312 );
311313 $ first = $ enqueuer ->enqueue ( $ job_id , 'ephemeral_step_0 ' );
312314 $ second = $ enqueuer ->enqueue ( $ job_id , 'ephemeral_step_0 ' );
@@ -329,11 +331,31 @@ static function () use ( &$scheduled_id ) {
329331 array ( 'operation_claimed_at ' => '2000-01-01 00:00:00 ' ),
330332 array ( 'job_id ' => $ crashed_job_id )
331333 );
332- $ recovered = ( new DirectJobEnqueuer ( $ jobs , static fn () => 92 , static fn () => 0 ) )->enqueue ( $ crashed_job_id , 'ephemeral_step_0 ' );
334+ $ recovered = ( new DirectJobEnqueuer ( $ jobs , static fn () => 92 , static fn () => 0 , static fn () => true ) )->enqueue ( $ crashed_job_id , 'ephemeral_step_0 ' );
333335 $ this ->assertTrue ( $ recovered ['success ' ] );
334336 $ this ->assertSame ( 'enqueued ' , $ jobs ->get_job ( $ crashed_job_id )['operation_state ' ] );
335337 }
336338
339+ public function test_positive_scheduler_id_without_receipt_is_reclaimable (): void {
340+ $ jobs = new Jobs ();
341+ $ job_id = $ jobs ->create_job (
342+ array (
343+ 'pipeline_id ' => 'direct ' ,
344+ 'flow_id ' => 'direct ' ,
345+ 'operation_state ' => 'preparing ' ,
346+ 'operation_step_id ' => 'ephemeral_step_0 ' ,
347+ )
348+ );
349+
350+ $ result = ( new DirectJobEnqueuer ( $ jobs , static fn () => 999999 , static fn () => 0 , static fn () => false ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
351+ $ job = $ jobs ->get_job ( $ job_id );
352+
353+ $ this ->assertFalse ( $ result ['success ' ] );
354+ $ this ->assertSame ( 'action_receipt_missing ' , $ result ['error ' ] );
355+ $ this ->assertSame ( 'enqueue_failed ' , $ job ['operation_state ' ] );
356+ $ this ->assertSame ( 0 , (int ) $ job ['operation_action_id ' ] );
357+ }
358+
337359 public function test_non_owner_gets_retryable_in_progress_until_action_is_durable (): void {
338360 $ jobs = new Jobs ();
339361 $ job_id = $ jobs ->create_job (
@@ -346,7 +368,7 @@ public function test_non_owner_gets_retryable_in_progress_until_action_is_durabl
346368 );
347369 $ this ->assertIsArray ( $ jobs ->claim_operation_enqueue ( $ job_id ) );
348370
349- $ result = ( new DirectJobEnqueuer ( $ jobs , static fn () => 99 , static fn () => 0 ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
371+ $ result = ( new DirectJobEnqueuer ( $ jobs , static fn () => 99 , static fn () => 0 , static fn () => true ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
350372
351373 $ this ->assertFalse ( $ result ['success ' ] );
352374 $ this ->assertTrue ( $ result ['retryable ' ] );
@@ -367,7 +389,7 @@ public function test_replay_commits_scheduled_action_after_submitter_crash(): vo
367389 $ claim = $ jobs ->claim_operation_enqueue ( $ job_id );
368390 $ this ->assertIsArray ( $ claim );
369391
370- $ result = ( new DirectJobEnqueuer ( $ jobs , static fn () => 999 , static fn () => 404 ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
392+ $ result = ( new DirectJobEnqueuer ( $ jobs , static fn () => 999 , static fn () => 404 , static fn () => true ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
371393 $ job = $ jobs ->get_job ( $ job_id );
372394
373395 $ this ->assertTrue ( $ result ['success ' ] );
@@ -402,7 +424,8 @@ static function () use ( &$takeover_claim, $jobs, $wpdb, $job_id ) {
402424 $ takeover_claim = $ jobs ->claim_operation_enqueue ( $ job_id );
403425 return 101 ;
404426 },
405- static fn () => 0
427+ static fn () => 0 ,
428+ static fn () => true
406429 );
407430
408431 $ slow_result = $ slow ->enqueue ( $ job_id , 'ephemeral_step_0 ' );
@@ -445,7 +468,8 @@ static function ( int $run_at, string $hook, array $args ) use ( &$worker_result
445468 $ worker_result = ( new ExecuteStepAbility () )->execute ( $ args );
446469 return 303 ;
447470 },
448- static fn () => 0
471+ static fn () => 0 ,
472+ static fn () => true
449473 ) )->enqueue ( $ job_id , 'ephemeral_step_0 ' );
450474
451475 $ this ->assertTrue ( $ worker_result ['deferred ' ] );
@@ -516,6 +540,85 @@ public function test_processing_direct_workflow_retry_waits_for_live_generation(
516540 $ this ->assertSame ( $ original_generation , (int ) $ job ['operation_generation ' ] );
517541 }
518542
543+ public function test_processing_direct_retry_atomically_requeues_missing_action (): void {
544+ global $ wpdb ;
545+
546+ wp_set_current_user ( $ this ->owner_id );
547+ $ created = $ this ->execute ( 'manual-missing-action-retry ' );
548+ $ jobs = new Jobs ();
549+ $ job_id = (int ) $ created ['job_id ' ];
550+ $ before = $ jobs ->get_job ( $ job_id );
551+ $ this ->assertTrue ( $ jobs ->start_job ( $ job_id ) );
552+ $ this ->assertSame ( 1 , $ wpdb ->delete ( $ wpdb ->prefix . 'actionscheduler_actions ' , array ( 'action_id ' => (int ) $ before ['operation_action_id ' ] ), array ( '%d ' ) ) );
553+ $ job_count = $ jobs ->get_jobs_count ();
554+
555+ $ retry = ( new RetryJobAbility () )->execute ( array ( 'job_id ' => $ job_id ) );
556+ $ after = $ jobs ->get_job ( $ job_id );
557+
558+ $ this ->assertTrue ( $ retry ['success ' ] );
559+ $ this ->assertTrue ( $ retry ['direct_requeued ' ] );
560+ $ this ->assertSame ( $ job_id , (int ) $ retry ['job_id ' ] );
561+ $ this ->assertSame ( $ job_count , $ jobs ->get_jobs_count () );
562+ $ this ->assertSame ( JobStatus::PENDING , $ after ['status ' ] );
563+ $ this ->assertSame ( 'enqueued ' , $ after ['operation_state ' ] );
564+ $ this ->assertGreaterThan ( (int ) $ before ['operation_generation ' ], (int ) $ after ['operation_generation ' ] );
565+ $ this ->assertTrue ( DirectOperationRecoveryPolicy::recordedActionExists ( (int ) $ after ['operation_action_id ' ] ) );
566+ $ this ->assertSame ( 'manual_retry ' , $ after ['engine_data ' ]['direct_operation_recovery ' ]['trigger ' ] );
567+ $ this ->assertNull ( $ after ['terminal_accounting_state ' ] );
568+
569+ $ duplicate = ( new RetryJobAbility () )->execute ( array ( 'job_id ' => $ job_id , 'force ' => true ) );
570+ $ this ->assertFalse ( $ duplicate ['success ' ] );
571+ $ this ->assertSame ( $ job_count , $ jobs ->get_jobs_count () );
572+ }
573+
574+ public function test_missing_direct_action_requeue_rolls_back_unusable_receipt (): void {
575+ global $ wpdb ;
576+
577+ wp_set_current_user ( $ this ->owner_id );
578+ $ created = $ this ->execute ( 'manual-missing-action-rollback ' );
579+ $ jobs = new Jobs ();
580+ $ job_id = (int ) $ created ['job_id ' ];
581+ $ before = $ jobs ->get_job ( $ job_id );
582+ $ this ->assertTrue ( $ jobs ->start_job ( $ job_id ) );
583+ $ this ->assertSame ( 1 , $ wpdb ->delete ( $ wpdb ->prefix . 'actionscheduler_actions ' , array ( 'action_id ' => (int ) $ before ['operation_action_id ' ] ), array ( '%d ' ) ) );
584+
585+ $ result = $ jobs ->commit_missing_direct_operation_requeue (
586+ $ job_id ,
587+ (int ) $ before ['operation_action_id ' ],
588+ (int ) $ before ['operation_generation ' ],
589+ (string ) $ before ['operation_claim_token ' ],
590+ 'manual_retry ' ,
591+ static fn (): int => 999999
592+ );
593+ $ after = $ jobs ->get_job ( $ job_id );
594+
595+ $ this ->assertFalse ( $ result ['success ' ] );
596+ $ this ->assertSame ( 'action_receipt_missing ' , $ result ['reason ' ] );
597+ $ this ->assertSame ( JobStatus::PROCESSING , $ after ['status ' ] );
598+ $ this ->assertSame ( (int ) $ before ['operation_generation ' ], (int ) $ after ['operation_generation ' ] );
599+ $ this ->assertSame ( (int ) $ before ['operation_action_id ' ], (int ) $ after ['operation_action_id ' ] );
600+ $ this ->assertArrayNotHasKey ( 'direct_operation_recovery ' , $ after ['engine_data ' ] );
601+ }
602+
603+ public function test_processing_direct_retry_rejects_missing_action_after_effects_begin (): void {
604+ global $ wpdb ;
605+
606+ wp_set_current_user ( $ this ->owner_id );
607+ $ created = $ this ->execute ( 'manual-missing-action-effects ' );
608+ $ jobs = new Jobs ();
609+ $ job_id = (int ) $ created ['job_id ' ];
610+ $ before = $ jobs ->get_job ( $ job_id );
611+ $ this ->assertTrue ( $ jobs ->start_job ( $ job_id ) );
612+ $ this ->assertTrue ( $ jobs ->mark_operation_effects_begun ( $ job_id , (int ) $ before ['operation_generation ' ], (string ) $ before ['operation_claim_token ' ] ) );
613+ $ this ->assertSame ( 1 , $ wpdb ->delete ( $ wpdb ->prefix . 'actionscheduler_actions ' , array ( 'action_id ' => (int ) $ before ['operation_action_id ' ] ), array ( '%d ' ) ) );
614+
615+ $ retry = ( new RetryJobAbility () )->execute ( array ( 'job_id ' => $ job_id ) );
616+
617+ $ this ->assertFalse ( $ retry ['success ' ] );
618+ $ this ->assertSame ( 'job_effects_begun ' , $ retry ['error_code ' ] );
619+ $ this ->assertSame ( JobStatus::PROCESSING , $ jobs ->get_job ( $ job_id )['status ' ] );
620+ }
621+
519622 public function test_processing_multistep_retry_detects_live_action_for_different_step (): void {
520623 wp_set_current_user ( $ this ->owner_id );
521624 $ created = $ this ->execute ( 'processing-multistep-retry ' );
0 commit comments