diff --git a/.github/workflows/update_as.yml b/.github/workflows/update_as.yml index 9e460af..bd1dc7b 100644 --- a/.github/workflows/update_as.yml +++ b/.github/workflows/update_as.yml @@ -64,7 +64,7 @@ jobs: run: | ${SLIC_BIN} up wordpress ${SLIC_BIN} wp core version - ${SLIC_BIN} wp core update --force --version=6.7 + ${SLIC_BIN} wp core update --force --version=latest ${SLIC_BIN} wp core version ${SLIC_BIN} use shepherd diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e090d6..bbfc576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [0.1.0] 2025-12-17 + +* Feature - Introduces a method `run` to the Regulator class which enables running a set of tasks synchronously. + +[0.0.9]: https://github.com/stellarwp/shepherd/releases/tag/0.0.9 + ## [0.0.9] 2025-11-17 * Tweak - Add a filter `shepherd_{prefix}_dispatch_handler` to allow for custom dispatch handlers. diff --git a/CLAUDE.md b/CLAUDE.md index 813de50..7e57d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ Shepherd is a lightweight background processing library for WordPress built on t ## Key Features - **Background Task Processing**: Offload time-consuming operations to background processes +- **Synchronous Task Execution**: Run tasks immediately with lifecycle callbacks via `run()` method (since 0.1.0) - **Automatic Retries**: Configurable retry mechanism with exponential backoff - **Task Debouncing**: Prevents tasks from running too frequently with customizable delays - **Unique Task Enforcement**: Prevents duplicate tasks from being scheduled @@ -105,6 +106,16 @@ shepherd()->dispatch(new My_Task($arg1, $arg2)); // Dispatch with delay (in seconds) shepherd()->dispatch(new My_Task($arg1, $arg2), 300); // 5 minutes +// Run tasks synchronously with lifecycle callbacks (since 0.1.0) +shepherd()->run( + [ new My_Task($arg1, $arg2), new Another_Task() ], + [ + 'before' => function( $task ) { /* called before each task */ }, + 'after' => function( $task ) { /* called after each task */ }, + 'always' => function( $tasks ) { /* called after all tasks complete successfully */ }, + ] +); + // Retrieve task logs use StellarWP\Shepherd\Contracts\Logger; use StellarWP\Shepherd\Provider; diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index a960e01..a849fab 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -371,6 +371,154 @@ The task tables include indexes on: - **Indexed Queries**: All cleanup queries use indexed columns for optimal performance - **Minimal Overhead**: Action deletion hooks add minimal overhead to Action Scheduler operations +## Synchronous Task Execution (Since 0.1.0) + +The `run()` method allows you to execute tasks synchronously with full control over the execution lifecycle. This is useful for CLI commands, REST API endpoints, or any scenario where you need tasks to execute immediately. + +### Basic Usage + +```php +use function StellarWP\Shepherd\shepherd; + +// Run a single task immediately +shepherd()->run( [ new My_Task() ] ); + +// Run multiple tasks in sequence +$tasks = [ + new Process_Image_Task( $image_id ), + new Generate_Thumbnail_Task( $image_id ), + new Update_Metadata_Task( $image_id ), +]; + +shepherd()->run( $tasks ); +``` + +### Lifecycle Callbacks + +The `run()` method accepts an optional array of callbacks for fine-grained control: + +```php +shepherd()->run( $tasks, [ + // Called before each task runs + 'before' => function( Task $task ): void { + error_log( 'Starting task: ' . get_class( $task ) ); + }, + + // Called after each task completes successfully + 'after' => function( Task $task ): void { + error_log( 'Completed task: ' . get_class( $task ) ); + }, + + // Called after all tasks complete (even on error) + 'always' => function( array $tasks ): void { + error_log( 'Finished processing ' . count( $tasks ) . ' tasks' ); + }, +] ); +``` + +### CLI Command Example + +```php +use WP_CLI; +use function StellarWP\Shepherd\shepherd; + +WP_CLI::add_command( 'myapp process-images', function( $args, $assoc_args ) { + $image_ids = get_unprocessed_image_ids(); + $tasks = array_map( + fn( $id ) => new Process_Image_Task( $id ), + $image_ids + ); + + $processed = 0; + $failed = 0; + + shepherd()->run( $tasks, [ + 'before' => function( Task $task ) { + WP_CLI::log( 'Processing image...' ); + }, + 'after' => function( Task $task ) use ( &$processed ) { + $processed++; + WP_CLI::success( 'Image processed!' ); + }, + 'always' => function( array $tasks ) use ( &$processed, &$failed ) { + WP_CLI::line( "Processed: {$processed}, Failed: {$failed}" ); + }, + ] ); +} ); +``` + +### REST API Example + +```php +use function StellarWP\Shepherd\shepherd; + +register_rest_route( 'myapp/v1', '/process', [ + 'methods' => 'POST', + 'callback' => function( WP_REST_Request $request ) { + $items = $request->get_param( 'items' ); + $tasks = array_map( + fn( $item ) => new Process_Item_Task( $item ), + $items + ); + + $results = [ + 'processed' => [], + 'failed' => [], + ]; + + shepherd()->run( $tasks, [ + 'after' => function( Task $task ) use ( &$results ) { + $results['processed'][] = $task->get_args()[0]; + }, + ] ); + + return new WP_REST_Response( $results, 200 ); + }, + 'permission_callback' => fn() => current_user_can( 'manage_options' ), +] ); +``` + +### Behavior Notes + +- **Already scheduled tasks**: If a task was previously dispatched via `dispatch()`, `run()` will execute it without re-dispatching +- **Fallback mode**: When Shepherd's database tables are not registered, tasks execute immediately via `process()` without Action Scheduler +- **Context detection**: Shepherd automatically detects CLI and REST contexts for proper logging +- **Exception handling**: Exceptions or Throwables thrown inside callables (`before`, `after`, `always`) are caught and trigger the `tasks_run_failed` action + +### WordPress Hooks + +Monitor synchronous task execution using WordPress actions: + +```php +$prefix = Config::get_hook_prefix(); + +// Fired before each task runs +add_action( "shepherd_{$prefix}_task_before_run", function( Task $task ) { + // Prepare for task execution +}, 10, 1 ); + +// Fired after each task completes +add_action( "shepherd_{$prefix}_task_after_run", function( Task $task ) { + // Post-task cleanup or notifications +}, 10, 1 ); + +// Fired when any task or callable fails (catches Exception and Throwable) +add_action( "shepherd_{$prefix}_tasks_run_failed", function( array $tasks, Throwable $e ) { + // Handle batch failure - receives all tasks and the exception/error + error_log( 'Tasks failed: ' . $e->getMessage() ); +}, 10, 2 ); + +// Fired after all tasks complete successfully +add_action( "shepherd_{$prefix}_tasks_finished", function( array $tasks ) { + // Batch completion handling +}, 10, 1 ); + +// Fired when tables aren't registered (fallback mode) +add_action( "shepherd_{$prefix}_task_run_sync", function( Task $task ) { + // Track fallback executions +}, 10, 1 ); +``` + ## Custom Dispatch Handlers **Since 0.0.9**, you can completely override Shepherd's default dispatch behavior by providing a custom handler via a filter. This is useful for advanced scenarios where you need full control over how tasks are dispatched. diff --git a/docs/api-reference.md b/docs/api-reference.md index 333b8bf..f35ac94 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -37,6 +37,32 @@ Schedules a task for execution. - `shepherd_{prefix}_task_scheduling_failed` - `shepherd_{prefix}_task_already_exists` +##### `run( array $tasks, array $callables = [] ): void` + +Runs a set of tasks synchronously with optional lifecycle callbacks. + +- **Parameters:** + - `$tasks` - Array of Task instances to run + - `$callables` - Optional array of lifecycle callbacks: + - `'before'` - `function( Task $task ): void` - Called before each task runs + - `'after'` - `function( Task $task ): void` - Called after each task completes + - `'always'` - `function( array $tasks ): void` - Called after all tasks complete (even on error) +- **Since:** 0.1.0 +- **Behavior:** + - When tables are registered: Dispatches tasks if not already scheduled, then processes them immediately using Action Scheduler's queue runner + - When tables are NOT registered: Processes tasks immediately in a synchronous manner (fallback) + - Tasks already scheduled (via `dispatch()`) will be executed without re-dispatching +- **Actions Fired:** + - `shepherd_{prefix}_task_run_sync` - When tables are not registered and task runs synchronously + - `shepherd_{prefix}_task_before_run` - Before each task is processed + - `shepherd_{prefix}_task_after_run` - After each task completes successfully + - `shepherd_{prefix}_tasks_run_failed` - When a task fails during the run + - `shepherd_{prefix}_tasks_finished` - After all tasks have been processed +- **Use Cases:** + - CLI commands that need immediate task execution + - REST API endpoints that need synchronous task processing + - Testing scenarios requiring controlled task execution + ##### `get_last_scheduled_task_id(): ?int` Returns the ID of the most recently scheduled task. @@ -526,6 +552,21 @@ Table name: `shepherd_{prefix}_task_logs` - `shepherd_{prefix}_http_request_processed` - Fired after successful HTTP request - Parameters: `$task` (HTTP_Request instance), `$response` (wp_remote_request response array) +- `shepherd_{prefix}_task_run_sync` - Fired when a task is run synchronously via `run()` when tables are not registered (since 0.1.0) + - Parameters: `$task` (Task instance) + +- `shepherd_{prefix}_task_before_run` - Fired before a task is processed via `run()` (since 0.1.0) + - Parameters: `$task` (Task instance) + +- `shepherd_{prefix}_task_after_run` - Fired after a task completes via `run()` (since 0.1.0) + - Parameters: `$task` (Task instance) + +- `shepherd_{prefix}_tasks_run_failed` - Fired when a task or callable fails during `run()` (since 0.1.0) + - Parameters: `$tasks` (array of Task instances), `$exception` (Exception or Throwable) + +- `shepherd_{prefix}_tasks_finished` - Fired after all tasks have been processed via `run()` (since 0.1.0) + - Parameters: `$tasks` (array of Task instances) + ### Filters - `shepherd_{prefix}_should_log` - Filter to control whether logging should occur (since 0.0.5) diff --git a/docs/getting-started.md b/docs/getting-started.md index 4ca065b..4612347 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -134,6 +134,9 @@ shepherd()->dispatch( $my_task ); // Or dispatch with a delay (in seconds) shepherd()->dispatch( $my_task, 5 * MINUTE_IN_SECONDS ); // Execute after 5 minutes + +// Run tasks immediately (synchronous execution - since 0.1.0) +shepherd()->run( [ $my_task ] ); ``` ### What Happens Next? diff --git a/shepherd.php b/shepherd.php index 80eab0d..fb6a22b 100644 --- a/shepherd.php +++ b/shepherd.php @@ -9,7 +9,7 @@ * @wordpress-plugin * Plugin Name: Shepherd * Description: A library for offloading tasks to background processes. - * Version: 0.0.9 + * Version: 0.1.0 * Author: StellarWP * Author URI: https://stellarwp.com * License: GPL-2.0-or-later diff --git a/src/Regulator.php b/src/Regulator.php index a55ba61..ddda1aa 100644 --- a/src/Regulator.php +++ b/src/Regulator.php @@ -16,7 +16,6 @@ use StellarWP\Shepherd\Contracts\Task; use StellarWP\Shepherd\Tables\Tasks as Tasks_Table; use RuntimeException; -use Exception; use Throwable; use StellarWP\DB\DB; use StellarWP\Shepherd\Exceptions\ShepherdTaskException; @@ -24,6 +23,8 @@ use StellarWP\Shepherd\Exceptions\ShepherdTaskFailWithoutRetryException; use StellarWP\Shepherd\Traits\Loggable; use StellarWP\Shepherd\Tasks\Herding; +use ActionScheduler_QueueRunner; +use WP_Object_Cache; /** * Shepherd's regulator. @@ -166,7 +167,7 @@ public function dispatch( Task $task, int $delay = 0 ): self { if ( null !== $handler && is_callable( $handler ) ) { try { $handler( $task, $delay ); - } catch ( Exception $e ) { + } catch ( Throwable $e ) { /** * Documented in the dispatch_callback method. */ @@ -325,6 +326,164 @@ protected function dispatch_callback( Task $task, int $delay ): void { } } + /** + * Run a set of tasks. + * + * @since 0.1.0 + * + * @param Task[] $tasks The tasks to run. + * @param array $callables The callables to run. + * + * @phpstan-param array{} | array{ + * before: callable( Task $task ): void, + * after: callable( Task $task ): void, + * always: callable( list $tasks ): void, + * } $callables + * + * @return void + */ + public function run( array $tasks, array $callables = [] ): void { + $prefix = Config::get_hook_prefix(); + + if ( ! did_action( "shepherd_{$prefix}_tables_registered" ) ) { + foreach ( $tasks as $task ) { + $task->process(); + + /** + * Fires an action when a task is run synchronously. + * + * @since 0.1.0 + * + * @param Task $task The task that was dispatched synchronously. + */ + do_action( "shepherd_{$prefix}_task_run_sync", $task ); + } + + return; + } + + if ( did_action( 'action_scheduler_init' ) || doing_action( 'action_scheduler_init' ) ) { + $this->run_callback( $tasks, $callables ); + return; + } + + add_action( + 'action_scheduler_init', + function () use ( $tasks, $callables ): void { + $this->run_callback( $tasks, $callables ); + }, + 10 + ); + } + + /** + * Runs a set of tasks. + * + * @since 0.1.0 + * + * @param Task[] $tasks The tasks to run. + * @param array $callables The callables to run. + * + * @phpstan-param array{} | array{ + * before: callable( Task $task ): void, + * after: callable( Task $task ): void, + * always: callable( list $tasks ): void, + * } $callables + * + * @return void + */ + private function run_callback( array $tasks, array $callables = [] ): void { + $callables = wp_parse_args( + $callables, + [ + 'before' => static function ( Task $task ): void {}, + 'after' => static function ( Task $task ): void {}, + 'always' => static function ( array $tasks ): void {}, + ] + ); + + $context = defined( 'WP_CLI' ) && WP_CLI ? ' CLI' : ''; + $context = ! $context && defined( 'REST_REQUEST' ) && REST_REQUEST ? ' REST' : $context; + $prefix = Config::get_hook_prefix(); + + $runner = ActionScheduler_QueueRunner::instance(); + + try { + + /** + * Filters the number of tasks to clean up after. + * + * @since 0.1.0 + * + * @param int $clean_up_memory_every The number of tasks to clean up the memory after. + * + * @return int The number of tasks to clean up the memory after. + */ + $clean_up_memory_every = (int) apply_filters( "shepherd_{$prefix}_clean_up_memory_every", 10 ); + + foreach ( array_values( $tasks ) as $offset => $task ) { + if ( ! in_array( $task->get_id(), $this->scheduled_tasks, true ) ) { + $this->dispatch_callback( $task, 0 ); + } + + if ( is_callable( $callables['before'] ) ) { + $callables['before']( $task ); + } + + /** + * Fires when a task is about to be run. + * + * @since 0.1.0 + * + * @param Task $task The task that is about to be run. + */ + do_action( "shepherd_{$prefix}_task_before_run", $task ); + + $runner->process_action( $task->get_action_id(), "Shepherd{$context}" ); + + if ( is_callable( $callables['after'] ) ) { + $callables['after']( $task ); + } + + /** + * Fires when a task is finished running. + * + * @since 0.1.0 + * + * @param Task $task The task that is finished running. + */ + do_action( "shepherd_{$prefix}_task_after_run", $task ); + + if ( 0 === ( $offset + 1 ) % $clean_up_memory_every ) { + $this->free_memory(); + } + } + + if ( is_callable( $callables['always'] ) ) { + $callables['always']( $tasks ); + } + + /** + * Fires when a set of tasks is finished running. + * + * @since 0.1.0 + * + * @param Task[] $tasks The tasks that were run. + */ + do_action( "shepherd_{$prefix}_tasks_finished", $tasks ); + } catch ( Throwable $e ) { + /** + * Fires when a set of tasks fails to be run. + * + * @since 0.1.0 + * + * @param Task[] $tasks The tasks that failed to be run. + * @param Throwable $e The exception that was thrown. + */ + do_action( "shepherd_{$prefix}_tasks_run_failed", $tasks, $e ); + } + } + /** * Gets the last scheduled task ID. * @@ -367,7 +526,6 @@ public function bust_runtime_cached_tasks(): void { * @throws RuntimeException If no action ID is found, no Shepherd task is found with the action ID, or the task arguments hash does not match the expected hash. * @throws ShepherdTaskException If the task fails to be processed. * @throws ShepherdTaskFailWithoutRetryException If the task fails to be processed without retry. - * @throws Exception If the task fails to be processed. * @throws Throwable If the task fails to be processed. */ public function process_task( string $args_hash ): void { @@ -443,23 +601,6 @@ public function process_task( string $args_hash ): void { $this->log_failed( $task->get_id(), array_merge( $log_data, [ 'exception' => $e->getMessage() ] ) ); - throw $e; - } catch ( Exception $e ) { - /** - * Fires when a task fails to be processed. - * - * @since 0.0.1 - * - * @param Task $task The task that failed to be processed. - * @param Exception $e The exception that was thrown. - */ - do_action( 'shepherd_' . Config::get_hook_prefix() . '_task_failed', $task, $e ); - - if ( $this->should_retry( $task ) ) { - throw new ShepherdTaskException( esc_html_x( 'The task failed, but will be retried.', 'This error is thrown when a task fails to be processed, but will be retried.', 'stellarwp-shepherd' ) ); - } - - $this->log_failed( $task->get_id(), array_merge( $log_data, [ 'exception' => $e->getMessage() ] ) ); throw $e; } catch ( Throwable $e ) { /** @@ -550,4 +691,45 @@ public function schedule_cleanup_task(): void { */ do_action( 'shepherd_' . Config::get_hook_prefix() . '_cleanup_task_scheduled' ); } + + /** + * Reduce memory footprint by clearing the database query and object caches. + * + * @since 0.1.0 + * + * @return void + */ + private function free_memory(): void { + /** + * Globals. + * + * @var \wpdb $wpdb + * @var WP_Object_Cache $wp_object_cache + */ + global $wpdb, $wp_object_cache; + + $wpdb->queries = []; + + if ( ! $wp_object_cache instanceof WP_Object_Cache ) { + return; + } + + // Not all drop-ins support these props, however, there may be existing installations that rely on these being cleared. + if ( property_exists( $wp_object_cache, 'group_ops' ) ) { + $wp_object_cache->group_ops = []; + } + if ( property_exists( $wp_object_cache, 'stats' ) ) { + $wp_object_cache->stats = []; + } + if ( property_exists( $wp_object_cache, 'memcache_debug' ) ) { + $wp_object_cache->memcache_debug = []; + } + if ( property_exists( $wp_object_cache, 'cache' ) ) { + $wp_object_cache->cache = []; + } + + if ( is_callable( [ $wp_object_cache, '__remoteset' ] ) ) { + call_user_func( [ $wp_object_cache, '__remoteset' ] ); // important! + } + } } diff --git a/tests/integration/Regulator_Test.php b/tests/integration/Regulator_Test.php index 5688506..6ed77ad 100644 --- a/tests/integration/Regulator_Test.php +++ b/tests/integration/Regulator_Test.php @@ -252,4 +252,209 @@ public function it_should_retry_task_and_succeed(): void { $this->assertMatchesLogSnapshot( $logs ); } + + /** + * @test + */ + public function it_should_run_tasks_and_log_lifecycle(): void { + $shepherd = shepherd(); + $this->assertNull( $shepherd->get_last_scheduled_task_id() ); + + $task1 = new Do_Prefixed_Action_Task( 'run_log_1' ); + $task2 = new Do_Prefixed_Action_Task( 'run_log_2' ); + + $this->assertSame( 0, did_action( $task1->get_task_name() ) ); + $this->assertSame( 0, did_action( $task2->get_task_name() ) ); + + $shepherd->run( [ $task1, $task2 ] ); + + $this->assertSame( 1, did_action( $task1->get_task_name() ) ); + $this->assertSame( 1, did_action( $task2->get_task_name() ) ); + + $logs1 = $this->get_logger()->retrieve_logs( $task1->get_id() ); + $this->assertCount( 3, $logs1 ); + $this->assertSame( 'created', $logs1[0]->get_type() ); + $this->assertSame( 'started', $logs1[1]->get_type() ); + $this->assertSame( 'finished', $logs1[2]->get_type() ); + + $logs2 = $this->get_logger()->retrieve_logs( $task2->get_id() ); + $this->assertCount( 3, $logs2 ); + $this->assertSame( 'created', $logs2[0]->get_type() ); + $this->assertSame( 'started', $logs2[1]->get_type() ); + $this->assertSame( 'finished', $logs2[2]->get_type() ); + } + + /** + * @test + */ + public function it_should_run_task_that_was_previously_dispatched(): void { + $shepherd = shepherd(); + + $task = new Do_Prefixed_Action_Task( 'run_dispatched' ); + + $shepherd->dispatch( $task ); + $task_id = $shepherd->get_last_scheduled_task_id(); + + $this->assertSame( 0, did_action( $task->get_task_name() ) ); + + $shepherd->run( [ $task ] ); + + $this->assertSame( 1, did_action( $task->get_task_name() ) ); + + $logs = $this->get_logger()->retrieve_logs( $task_id ); + $this->assertCount( 3, $logs ); + $this->assertSame( 'created', $logs[0]->get_type() ); + $this->assertSame( 'started', $logs[1]->get_type() ); + $this->assertSame( 'finished', $logs[2]->get_type() ); + } + + /** + * @test + */ + public function it_should_run_single_task_successfully(): void { + $shepherd = shepherd(); + + $task = new Do_Action_Task(); + + $before_called = false; + $after_called = false; + $always_called = false; + + $shepherd->run( [ $task ], [ + 'before' => function() use ( &$before_called ) { + $before_called = true; + }, + 'after' => function() use ( &$after_called ) { + $after_called = true; + }, + 'always' => function() use ( &$always_called ) { + $always_called = true; + }, + ] ); + + $this->assertSame( 1, did_action( $task->get_task_name() ) ); + $this->assertTrue( $before_called, 'before callable should have been called' ); + $this->assertTrue( $after_called, 'after callable should have been called' ); + $this->assertTrue( $always_called, 'always callable should have been called' ); + } + + /** + * @test + */ + public function it_should_catch_exception_thrown_in_before_callable(): void { + $shepherd = shepherd(); + $prefix = tests_shepherd_get_hook_prefix(); + + $task = new Do_Action_Task(); + + $run_failed_count = did_action( "shepherd_{$prefix}_tasks_run_failed" ); + $captured_tasks = []; + $captured_exception = null; + + add_action( "shepherd_{$prefix}_tasks_run_failed", function( $tasks, $e ) use ( &$captured_tasks, &$captured_exception ) { + $captured_tasks[] = $tasks; + $captured_exception = $e; + }, 10, 2 ); + + $shepherd->run( [ $task ], [ + 'before' => function() { + throw new Exception( 'Before callable failed' ); + }, + ] ); + + $this->assertSame( $run_failed_count + 1, did_action( "shepherd_{$prefix}_tasks_run_failed" ), 'tasks_run_failed action should have fired' ); + $this->assertIsArray( $captured_tasks ); + $this->assertCount( 1, $captured_tasks ); + $this->assertInstanceOf( Exception::class, $captured_exception ); + $this->assertSame( 'Before callable failed', $captured_exception->getMessage() ); + } + + /** + * @test + */ + public function it_should_catch_exception_thrown_in_after_callable(): void { + $shepherd = shepherd(); + $prefix = tests_shepherd_get_hook_prefix(); + + $task = new Do_Action_Task(); + + $run_failed_count = did_action( "shepherd_{$prefix}_tasks_run_failed" ); + $captured_exception = null; + + add_action( "shepherd_{$prefix}_tasks_run_failed", function( $tasks, $e ) use ( &$captured_exception ) { + $captured_exception = $e; + }, 10, 2 ); + + $shepherd->run( [ $task ], [ + 'after' => function() { + throw new Exception( 'After callable failed' ); + }, + ] ); + + // Task should have run before the after callable threw + $this->assertSame( 1, did_action( $task->get_task_name() ), 'Task should have run before after callable failed' ); + $this->assertSame( $run_failed_count + 1, did_action( "shepherd_{$prefix}_tasks_run_failed" ), 'tasks_run_failed action should have fired' ); + $this->assertInstanceOf( Exception::class, $captured_exception ); + $this->assertSame( 'After callable failed', $captured_exception->getMessage() ); + } + + /** + * @test + */ + public function it_should_catch_exception_thrown_in_always_callable(): void { + $shepherd = shepherd(); + $prefix = tests_shepherd_get_hook_prefix(); + + $task = new Do_Action_Task(); + + $run_failed_count = did_action( "shepherd_{$prefix}_tasks_run_failed" ); + $tasks_finished_count = did_action( "shepherd_{$prefix}_tasks_finished" ); + $captured_exception = null; + + add_action( "shepherd_{$prefix}_tasks_run_failed", function( $tasks, $e ) use ( &$captured_exception ) { + $captured_exception = $e; + }, 10, 2 ); + + $shepherd->run( [ $task ], [ + 'always' => function() { + throw new Exception( 'Always callable failed' ); + }, + ] ); + + // Task should have run successfully + $this->assertSame( 1, did_action( $task->get_task_name() ), 'Task should have run' ); + // tasks_finished should NOT have fired because always callable threw before it + $this->assertSame( $tasks_finished_count, did_action( "shepherd_{$prefix}_tasks_finished" ), 'tasks_finished should not have fired' ); + // tasks_run_failed should have fired + $this->assertSame( $run_failed_count + 1, did_action( "shepherd_{$prefix}_tasks_run_failed" ), 'tasks_run_failed action should have fired' ); + $this->assertInstanceOf( Exception::class, $captured_exception ); + $this->assertSame( 'Always callable failed', $captured_exception->getMessage() ); + } + + /** + * @test + */ + public function it_should_catch_throwable_in_callable(): void { + $shepherd = shepherd(); + $prefix = tests_shepherd_get_hook_prefix(); + + $task = new Do_Action_Task(); + + $run_failed_count = did_action( "shepherd_{$prefix}_tasks_run_failed" ); + $captured_throwable = null; + + add_action( "shepherd_{$prefix}_tasks_run_failed", function( $tasks, $e ) use ( &$captured_throwable ) { + $captured_throwable = $e; + }, 10, 2 ); + + $shepherd->run( [ $task ], [ + 'before' => function() { + throw new \Error( 'Type error in callable' ); + }, + ] ); + + $this->assertSame( $run_failed_count + 1, did_action( "shepherd_{$prefix}_tasks_run_failed" ), 'tasks_run_failed action should have fired for Throwable' ); + $this->assertInstanceOf( \Throwable::class, $captured_throwable ); + $this->assertSame( 'Type error in callable', $captured_throwable->getMessage() ); + } } diff --git a/tests/wpunit/Regulator_Test.php b/tests/wpunit/Regulator_Test.php index 634752d..d8fa11a 100644 --- a/tests/wpunit/Regulator_Test.php +++ b/tests/wpunit/Regulator_Test.php @@ -327,4 +327,165 @@ public function it_should_do_default_when_returning_non_callable(): void { $this->assertNotNull( $regulator->get_last_scheduled_task_id(), 'Task should be scheduled when custom handler is not callable' ); } + + /** + * @test + */ + public function it_should_run_tasks_synchronously_when_tables_not_registered(): void { + $prefix = Config::get_hook_prefix(); + + $this->set_fn_return( 'did_action', function( $action ) use ( $prefix ) { + if ( $action === "shepherd_{$prefix}_tables_registered" ) { + return 0; + } + + return did_action( $action ); + }, true ); + + $regulator = Config::get_container()->get( Regulator::class ); + + $task1 = new Do_Prefixed_Action_Task( 'run_task_1' ); + $task2 = new Do_Prefixed_Action_Task( 'run_task_2' ); + + $this->assertSame( 0, did_action( $task1->get_task_name() ) ); + $this->assertSame( 0, did_action( $task2->get_task_name() ) ); + + $sync_run_count = 0; + add_action( "shepherd_{$prefix}_task_run_sync", function() use ( &$sync_run_count ) { + $sync_run_count++; + } ); + + $regulator->run( [ $task1, $task2 ] ); + + $this->assertSame( 1, did_action( $task1->get_task_name() ), 'First task should have run' ); + $this->assertSame( 1, did_action( $task2->get_task_name() ), 'Second task should have run' ); + $this->assertSame( 2, $sync_run_count, 'sync action should have fired twice' ); + } + + /** + * @test + */ + public function it_should_run_tasks_and_dispatch_when_tables_registered(): void { + $prefix = Config::get_hook_prefix(); + $regulator = Config::get_container()->get( Regulator::class ); + + $task1 = new Do_Prefixed_Action_Task( 'run_dispatch_1' ); + $task2 = new Do_Prefixed_Action_Task( 'run_dispatch_2' ); + + $this->assertSame( 0, did_action( $task1->get_task_name() ) ); + $this->assertSame( 0, did_action( $task2->get_task_name() ) ); + + $before_run_count = 0; + $after_run_count = 0; + $tasks_finished_count = 0; + + add_action( "shepherd_{$prefix}_task_before_run", function() use ( &$before_run_count ) { + $before_run_count++; + } ); + + add_action( "shepherd_{$prefix}_task_after_run", function() use ( &$after_run_count ) { + $after_run_count++; + } ); + + add_action( "shepherd_{$prefix}_tasks_finished", function() use ( &$tasks_finished_count ) { + $tasks_finished_count++; + } ); + + $regulator->run( [ $task1, $task2 ] ); + + $this->assertSame( 1, did_action( $task1->get_task_name() ), 'First task should have run' ); + $this->assertSame( 1, did_action( $task2->get_task_name() ), 'Second task should have run' ); + $this->assertSame( 2, $before_run_count, 'before_run action should have fired twice' ); + $this->assertSame( 2, $after_run_count, 'after_run action should have fired twice' ); + $this->assertSame( 1, $tasks_finished_count, 'tasks_finished action should have fired once' ); + } + + /** + * @test + */ + public function it_should_execute_before_callable_for_each_task(): void { + $regulator = Config::get_container()->get( Regulator::class ); + + $task1 = new Do_Prefixed_Action_Task( 'before_callable_1' ); + $task2 = new Do_Prefixed_Action_Task( 'before_callable_2' ); + + $before_tasks = []; + + $regulator->run( [ $task1, $task2 ], [ + 'before' => function( $task ) use ( &$before_tasks ) { + $before_tasks[] = $task; + }, + ] ); + + $this->assertCount( 2, $before_tasks ); + $this->assertSame( $task1->get_args_hash(), $before_tasks[0]->get_args_hash() ); + $this->assertSame( $task2->get_args_hash(), $before_tasks[1]->get_args_hash() ); + } + + /** + * @test + */ + public function it_should_execute_after_callable_for_each_task(): void { + $regulator = Config::get_container()->get( Regulator::class ); + + $task1 = new Do_Prefixed_Action_Task( 'after_callable_1' ); + $task2 = new Do_Prefixed_Action_Task( 'after_callable_2' ); + + $after_tasks = []; + + $regulator->run( [ $task1, $task2 ], [ + 'after' => function( $task ) use ( &$after_tasks ) { + $after_tasks[] = $task; + }, + ] ); + + $this->assertCount( 2, $after_tasks ); + $this->assertSame( $task1->get_args_hash(), $after_tasks[0]->get_args_hash() ); + $this->assertSame( $task2->get_args_hash(), $after_tasks[1]->get_args_hash() ); + } + + /** + * @test + */ + public function it_should_execute_always_callable_after_all_tasks(): void { + $regulator = Config::get_container()->get( Regulator::class ); + + $task1 = new Do_Prefixed_Action_Task( 'always_callable_1' ); + $task2 = new Do_Prefixed_Action_Task( 'always_callable_2' ); + + $always_executed = false; + $always_tasks = null; + + $regulator->run( [ $task1, $task2 ], [ + 'always' => function( $tasks ) use ( &$always_executed, &$always_tasks ) { + $always_executed = true; + $always_tasks = $tasks; + }, + ] ); + + $this->assertTrue( $always_executed, 'always callable should have been executed' ); + $this->assertCount( 2, $always_tasks ); + } + + /** + * @test + */ + public function it_should_not_dispatch_already_scheduled_tasks_when_running(): void { + $prefix = Config::get_hook_prefix(); + $regulator = Config::get_container()->get( Regulator::class ); + + $task = new Do_Prefixed_Action_Task( 'already_scheduled_run' ); + + // First dispatch the task + $regulator->dispatch( $task ); + $first_task_id = $regulator->get_last_scheduled_task_id(); + + $created_count = did_action( "shepherd_{$prefix}_task_created" ); + + // Now run it - should not dispatch again since it's already scheduled + $regulator->run( [ $task ] ); + + // Check that task_created was NOT fired again + $this->assertSame( $created_count, did_action( "shepherd_{$prefix}_task_created" ), 'Task should not be dispatched again if already scheduled' ); + } }