From c4b945bfe07edcdec6cd4c7543c9c29dc14c51da Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Wed, 29 Nov 2023 11:54:30 -0500 Subject: [PATCH] Queue UI (#458) * Continued work on queue UI= * Wrapping up the core of the queue UI * Working table for queue UI * Adding counts to the queue * Switch to use wp_count_posts for performance * Style the row table * Adding single view for queue job * Adding queue UI with fixes for retrying --- config/queue.php | 29 +- .../query/class-post-query-builder.php | 1 + .../queue/class-queue-service-provider.php | 1 + src/mantle/queue/class-worker.php | 2 +- .../admin/class-queue-job-admin-page.php | 104 ++++++ .../admin/class-queue-jobs-table.php | 314 ++++++++++++++++++ .../admin/class-service-provider.php | 52 +++ .../wordpress/admin/template/single.php | 178 ++++++++++ .../wordpress/admin/template/table.php | 31 ++ .../providers/wordpress/class-provider.php | 7 +- .../providers/wordpress/class-queue-job.php | 14 + .../wordpress/class-queue-worker-job.php | 24 +- .../wordpress/class-service-provider.php | 10 + 13 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 src/mantle/queue/providers/wordpress/admin/class-queue-job-admin-page.php create mode 100644 src/mantle/queue/providers/wordpress/admin/class-queue-jobs-table.php create mode 100644 src/mantle/queue/providers/wordpress/admin/class-service-provider.php create mode 100644 src/mantle/queue/providers/wordpress/admin/template/single.php create mode 100644 src/mantle/queue/providers/wordpress/admin/template/table.php diff --git a/config/queue.php b/config/queue.php index 76b5da63..384c6d9a 100644 --- a/config/queue.php +++ b/config/queue.php @@ -15,7 +15,7 @@ | Define the queue provider used in the application. | */ - 'default' => environment( 'QUEUE_CONNECTION', 'wordpress' ), + 'default' => environment( 'QUEUE_CONNECTION', 'wordpress' ), /* |-------------------------------------------------------------------------- @@ -25,7 +25,30 @@ | The amount of items handled in one run of the queue. | */ - 'batch_size' => environment( 'QUEUE_BATCH_SIZE', 100 ), + 'batch_size' => environment( 'QUEUE_BATCH_SIZE', 5 ), + + /* + |-------------------------------------------------------------------------- + | Maximum number of concurrent batches + |-------------------------------------------------------------------------- + | + | The maximum number of batches that can be run concurrently. For example, + | if 1000 queue jobs are dispatched and this is set to 5 with a batch size + | of 100, then 5 batches of 100 will be run concurrently and take two runs + | of the queue to complete. + | + */ + 'max_concurrent_batches' => environment( 'QUEUE_MAX_CONCURRENT_BATCHES', 1 ), + + /* + |-------------------------------------------------------------------------- + | Enable the Queue Admin Interface + |-------------------------------------------------------------------------- + | + | Enable the queue admin interface to display queue jobs. + | + */ + 'enable_admin' => environment( 'QUEUE_ENABLE_ADMIN', true ), /* |-------------------------------------------------------------------------- @@ -35,7 +58,7 @@ | Control the configuration for the queue providers. | */ - 'wordpress' => [ + 'wordpress' => [ // Delay between queue runs in seconds. 'delay' => environment( 'QUEUE_DELAY', 0 ), ], diff --git a/src/mantle/database/query/class-post-query-builder.php b/src/mantle/database/query/class-post-query-builder.php index 995f4dbd..50444e3b 100644 --- a/src/mantle/database/query/class-post-query-builder.php +++ b/src/mantle/database/query/class-post-query-builder.php @@ -49,6 +49,7 @@ class Post_Query_Builder extends Builder { 'post_author' => 'author', 'post_name' => 'name', 'slug' => 'name', + 'status' => 'post_status', ]; /** diff --git a/src/mantle/queue/class-queue-service-provider.php b/src/mantle/queue/class-queue-service-provider.php index af839ca7..da1fd4c1 100644 --- a/src/mantle/queue/class-queue-service-provider.php +++ b/src/mantle/queue/class-queue-service-provider.php @@ -48,6 +48,7 @@ public function register() { fn ( $app ) => new Dispatcher( $app ), ); + // Register queue console commands. $this->add_command( Run_Command::class ); // Register the queue service providers. diff --git a/src/mantle/queue/class-worker.php b/src/mantle/queue/class-worker.php index 983a644d..cf9dd3ab 100644 --- a/src/mantle/queue/class-worker.php +++ b/src/mantle/queue/class-worker.php @@ -58,7 +58,7 @@ function( Queue_Worker_Job $job ) use ( $provider ) { $this->events->dispatch( new Job_Failed( $provider, $job, $e ) ); } finally { - // TODO: Revisit this and don't delete the job. unlock it and let it be retried. + // TODO: Don't delete after completion. if ( ! $job->has_failed() ) { $job->delete(); } elseif ( $job->can_retry() ) { diff --git a/src/mantle/queue/providers/wordpress/admin/class-queue-job-admin-page.php b/src/mantle/queue/providers/wordpress/admin/class-queue-job-admin-page.php new file mode 100644 index 00000000..58205130 --- /dev/null +++ b/src/mantle/queue/providers/wordpress/admin/class-queue-job-admin-page.php @@ -0,0 +1,104 @@ + $this->render_action( $job_id ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! empty( $job_id ) => $this->render_single_job( $job_id ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended + default => $this->render_table(), + }; + } + + /** + * Render a single job view. + * + * @param int $job_id The job ID. + */ + protected function render_single_job( int $job_id ): void { + $job = Queue_Job::find( $job_id ); + + if ( empty( $job ) ) { + wp_die( esc_html__( 'Invalid job ID.', 'mantle' ) ); + } + + include __DIR__ . '/template/single.php'; + } + + /** + * Handle an action (retry/delete). + * + * @param int $job_id The job ID. + */ + protected function render_action( int $job_id ): void { + if ( + empty( $_GET['_wpnonce'] ) + || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'queue-job-action-' . $job_id ) + ) { + wp_die( 'Invalid nonce.' ); + } + + $action = sanitize_text_field( wp_unslash( $_GET['action'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $job = Queue_Job::find( $job_id ); + $message = ''; + + if ( empty( $job ) ) { + wp_die( esc_html__( 'Invalid job ID.', 'mantle' ) ); + } + + if ( 'retry' === $action ) { + if ( Post_Status::FAILED->value !== $job->status ) { + wp_die( esc_html__( 'Job is not in a failed state.', 'mantle' ) ); + } + + ( new Queue_Worker_Job( $job ) )->retry(); + + $message = esc_html__( 'Job has been scheduled to be retried.', 'mantle' ); + } elseif ( 'delete' === $action ) { + $job->delete( true ); + + $message = esc_html__( 'Job has been deleted.', 'mantle' ); + } + + if ( ! empty( $message ) ) { + printf( + '

%s

', + esc_html( $message ) + ); + } + + $this->render_table(); + } + + /** + * Render the queue table. + */ + protected function render_table(): void { + $table = new Queue_Jobs_Table(); + + $table->prepare_items(); + + include __DIR__ . '/template/table.php'; + } +} diff --git a/src/mantle/queue/providers/wordpress/admin/class-queue-jobs-table.php b/src/mantle/queue/providers/wordpress/admin/class-queue-jobs-table.php new file mode 100644 index 00000000..e6f563bb --- /dev/null +++ b/src/mantle/queue/providers/wordpress/admin/class-queue-jobs-table.php @@ -0,0 +1,314 @@ + __( 'Jobs', 'mantle' ), + 'singular' => __( 'Job', 'mantle' ), + ] + ); + } + + /** + * Gets the list of columns. + * + * @return string[] Array of column titles keyed by their column name. + */ + public function get_columns() { + return [ + 'job' => __( 'Job', 'mantle' ), + 'arguments' => __( 'Arguments', 'mantle' ), + 'queue' => __( 'Queue', 'mantle' ), + 'date' => __( 'Scheduled', 'mantle' ), + 'status' => __( 'Status', 'mantle' ), + ]; + } + + /** + * Collect the views for the table. + * + * @return array + */ + protected function get_views() { + $current = sanitize_text_field( wp_unslash( $_GET['filter'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $links = [ + [ + 'current' => empty( $current ), + 'label' => __( 'All', 'mantle' ), + 'url' => add_query_arg( 'filter', '' ), + ], + ]; + + foreach ( Post_Status::cases() as $status ) { + $count = $this->get_status_count( $status ); + + $links[] = [ + 'current' => $status->value === $current, + 'label' => str( $status->name ) + ->title() + ->when( + $count > 0, + fn ( $str ) => $str->append( + sprintf( + ' (%d)', + esc_html( number_format_i18n( $count ) ), + ), + ), + ) + ->toString(), + 'url' => add_query_arg( 'filter', $status->value ), + ]; + } + + return $this->get_views_links( $links ); + } + + /** + * Retrieve the count of items on a specific status. + * + * @param Post_Status $status The status to retrieve the count for. + */ + protected function get_status_count( Post_Status $status ): int { + $count = wp_count_posts( Provider::OBJECT_NAME ); + + return $count->{$status->value} ?? 0; + } + + /** + * Prepares the list of items for displaying. + */ + public function prepare_items() { + $this->_column_headers = [ $this->get_columns(), [], [] ]; + + $statuses = array_column( Post_Status::cases(), 'value' ); + + $active_status_filter = sanitize_text_field( wp_unslash( $_GET['filter'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + // Validate that the status filter is valid. + if ( ! empty( $active_status_filter ) && ! in_array( $active_status_filter, $statuses, true ) ) { + $active_status_filter = ''; + } + + $active_queue_filter = sanitize_text_field( wp_unslash( $_GET['queue'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $page = (int) ( $_GET['paged'] ?? 1 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + $query = Queue_Job::query() + ->orderBy( 'date', 'asc' ) + // Allow the query to be filtered by status. + ->when( + ! empty( $active_status_filter ), + fn ( $query ) => $query->where( 'post_status', $active_status_filter ), + fn ( $query ) => $query->where( 'post_status', $statuses ), + ) + // Allow the query to be filtered by queue. + ->when( + ! empty( $active_queue_filter ), + fn ( Post_Query_Builder $query ) => $query->whereTerm( + Provider::get_queue_term_id( $active_queue_filter, false ), + Provider::OBJECT_NAME, + ), + ) + ->for_page( $page, $this->per_page ); + + // TODO: Refactor with found_posts later. + $this->items = $query->get()->map( + function ( Queue_Job $model ) { + $worker = new Queue_Worker_Job( $model ); + $job = $worker->get_job(); + + return [ + 'id' => $model->ID, + 'job' => $worker->get_id(), + 'arguments' => is_object( $job ) ? get_object_vars( $job ) : '', + 'queue' => $model->get_queue(), + 'date' => $model->date, + 'status' => $model->status, + ]; + } + )->all(); + + $this->set_pagination_args( + [ + 'total_items' => $query->get_found_rows(), + 'per_page' => $this->per_page, + ] + ); + } + + /** + * Display the job column. + * + * @param array $item The current item. + */ + public function column_job( $item ): void { + $actions = [ + sprintf( + '%s', + esc_url( + add_query_arg( + [ + '_wpnonce' => false, + 'action' => false, + 'filter' => false, + 'job' => (int) $item['id'], + ] + ) + ), + esc_attr__( 'View details about this job', 'mantle' ), + esc_html__( 'View', 'mantle' ), + ), + Post_Status::FAILED->value === $item['status'] + ? sprintf( + '%s', + esc_url( + add_query_arg( + [ + '_wpnonce' => wp_create_nonce( 'queue-job-action-' . $item['id'] ), + 'filter' => false, + 'job' => (int) $item['id'], + 'action' => 'retry', + ] + ) + ), + esc_attr__( 'Retry this job', 'mantle' ), + esc_html__( 'Retry', 'mantle' ), + ) + : null, + Post_Status::RUNNING->value !== $item['status'] + ? sprintf( + '%s', + esc_url( + add_query_arg( + [ + '_wpnonce' => wp_create_nonce( 'queue-job-action-' . $item['id'] ), + 'filter' => false, + 'job' => (int) $item['id'], + 'action' => 'delete', + ] + ) + ), + esc_attr__( 'Delete this job', 'mantle' ), + "return confirm('" . esc_attr__( 'Are you sure you want to retry this job?', 'mantle' ) . "');", + esc_html__( 'Delete', 'mantle' ), + ) + : null, + ]; + + printf( + '
%s
%s
', + esc_html( $item['job'] ), + implode( ' | ', array_filter( $actions ) ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } + + /** + * Display the arguments column. + * + * @param array $item The current item. + */ + public function column_arguments( $item ): void { + echo '' . wp_json_encode( $item['arguments'] ) . ''; + } + + /** + * Display the queue column. + * + * @param array $item The current item. + */ + public function column_queue( $item ): void { + printf( + '%s', + esc_url( add_query_arg( 'queue', $item['queue'] ) ), + esc_html( $item['queue'] ), + ); + } + + /** + * Display the date column. + * + * @param array $item The current item. + */ + public function column_date( $item ): void { + $time = Carbon::parse( $item['date'], wp_timezone() ); + + printf( + '', + esc_attr( $time->format( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ), + esc_attr( $time->format( 'c' ) ), + esc_html( $time->diffForHumans() ), + ); + } + + /** + * Display the status column. + * + * @param array $item The current item. + */ + public function column_status( $item ): void { + switch ( $item['status'] ) { + case Post_Status::PENDING->value: + echo '' . esc_html__( 'Pending', 'mantle' ); + break; + + case Post_Status::RUNNING->value: + echo '' . esc_html__( 'Running', 'mantle' ); + break; + + case Post_Status::FAILED->value: + echo '' . esc_html__( 'Failed', 'mantle' ); + break; + } + } + + /** + * Gets the name of the default primary column. + */ + protected function get_default_primary_column_name() { + return 'job'; + } + + /** + * Generates content for a single row of the table. + * + * @param array $item The current item. + */ + public function single_row( $item ) { + printf( '', esc_attr( 'queue-item queue-item__' . $item['status'] ) ); + $this->single_row_columns( $item ); + echo ''; + } +} diff --git a/src/mantle/queue/providers/wordpress/admin/class-service-provider.php b/src/mantle/queue/providers/wordpress/admin/class-service-provider.php new file mode 100644 index 00000000..07ac8ddc --- /dev/null +++ b/src/mantle/queue/providers/wordpress/admin/class-service-provider.php @@ -0,0 +1,52 @@ +render(); + } +} diff --git a/src/mantle/queue/providers/wordpress/admin/template/single.php b/src/mantle/queue/providers/wordpress/admin/template/single.php new file mode 100644 index 00000000..e13e34bc --- /dev/null +++ b/src/mantle/queue/providers/wordpress/admin/template/single.php @@ -0,0 +1,178 @@ +get_meta( Meta_Key::LOG->value, true ); +$log = is_array( $log ) ? $log : []; + +?> +
+

+ id, + /* phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped */ + match ( $job->status ) { + Post_Status::PENDING->value => esc_html__( 'Pending', 'mantle' ), + Post_Status::FAILED->value => esc_html__( 'Failed', 'mantle' ), + Post_Status::RUNNING->value => esc_html__( 'Running', 'mantle' ), + default => esc_html__( 'Unknown', 'mantle' ), + }, + /* phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped */ + ); + ?> +

+ +
+ +
+ + +
+

+
    + +
  1. +

    + + + + — + + format( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ); ?> + + — + diffForHumans() ); ?> + +

    + + +
    + +
  2. + +
+
+
+
+ + diff --git a/src/mantle/queue/providers/wordpress/admin/template/table.php b/src/mantle/queue/providers/wordpress/admin/template/table.php new file mode 100644 index 00000000..fc56cb7b --- /dev/null +++ b/src/mantle/queue/providers/wordpress/admin/template/table.php @@ -0,0 +1,31 @@ + +
+

+ +
+ + views(); ?> + display(); ?> +
+ + diff --git a/src/mantle/queue/providers/wordpress/class-provider.php b/src/mantle/queue/providers/wordpress/class-provider.php index 95268d19..07d63ae3 100644 --- a/src/mantle/queue/providers/wordpress/class-provider.php +++ b/src/mantle/queue/providers/wordpress/class-provider.php @@ -236,17 +236,22 @@ public function in_queue( mixed $job, string $queue = null ): bool { * Get the taxonomy term for a queue. * * @param string|null $name Queue name, optional. + * @param bool $create Whether to create the term if it doesn't exist. * @return int * * @throws InvalidArgumentException Thrown on invalid queue term. */ - public static function get_queue_term_id( ?string $name = null ): int { + public static function get_queue_term_id( ?string $name = null, bool $create = true ): int { if ( ! $name ) { $name = 'default'; } $term = \get_term_by( 'slug', $name, static::OBJECT_NAME ); + if ( empty( $term ) && ! $create ) { + return 0; + } + if ( empty( $term ) ) { $insert = \wp_insert_term( $name, static::OBJECT_NAME, [ 'slug' => $name ] ); diff --git a/src/mantle/queue/providers/wordpress/class-queue-job.php b/src/mantle/queue/providers/wordpress/class-queue-job.php index 64e9eca6..c1b371fa 100644 --- a/src/mantle/queue/providers/wordpress/class-queue-job.php +++ b/src/mantle/queue/providers/wordpress/class-queue-job.php @@ -8,6 +8,9 @@ namespace Mantle\Queue\Providers\WordPress; use Mantle\Database\Model\Post; +use WordPressCS\WordPress\Sniffs\CodeAnalysis\EmptyStatementSniff; + +use function Mantle\Support\Helpers\collect; /** * Queue Job Data Model (for internal use only). @@ -81,4 +84,15 @@ public function log( Event $event, array $payload = [] ): void { $this->set_meta( Meta_Key::LOG->value, $meta ); } + + /** + * Retrieve the queue name. + * + * @return string + */ + public function get_queue(): string { + return collect( $this->get_terms( Provider::OBJECT_NAME ) ) + ->pluck( 'name' ) + ->first( null, '' ); + } } diff --git a/src/mantle/queue/providers/wordpress/class-queue-worker-job.php b/src/mantle/queue/providers/wordpress/class-queue-worker-job.php index f1a63061..3e540e60 100644 --- a/src/mantle/queue/providers/wordpress/class-queue-worker-job.php +++ b/src/mantle/queue/providers/wordpress/class-queue-worker-job.php @@ -7,7 +7,12 @@ namespace Mantle\Queue\Providers\WordPress; +use Mantle\Application\Application; +use Mantle\Contracts\Events\Dispatcher; use Mantle\Contracts\Queue\Job as JobContract; +use Mantle\Contracts\Queue\Queue_Manager; +use Mantle\Queue\Closure_Job; +use Mantle\Queue\Events\Job_Queued; use Throwable; /** @@ -66,7 +71,13 @@ public function fire(): void { * @return mixed */ public function get_id(): mixed { - return $this->model->id(); + $job = $this->get_job(); + + return match ( true ) { + $job instanceof Closure_Job => $job->get_id(), + is_object( $job ) => $job::class, + default => $this->model->id(), + }; } /** @@ -83,6 +94,7 @@ public function failed( Throwable $e ): void { [ 'exception' => $e::class, 'message' => $e->getMessage(), + 'trace' => explode( "\n", $e->getTraceAsString() ), ], ); @@ -124,6 +136,16 @@ public function retry( int $delay = 0 ): void { 'post_status' => Post_Status::PENDING->value, ] ); + + $app = Application::get_instance(); + + // Dispatch the job queued event. + $app['events']->dispatch( + new Job_Queued( + $app->make( Queue_Manager::class )->get_provider(), + $this->get_job(), + ), + ); } /** diff --git a/src/mantle/queue/providers/wordpress/class-service-provider.php b/src/mantle/queue/providers/wordpress/class-service-provider.php index c06da503..e981b5ae 100644 --- a/src/mantle/queue/providers/wordpress/class-service-provider.php +++ b/src/mantle/queue/providers/wordpress/class-service-provider.php @@ -17,6 +17,16 @@ * WordPress Queue Service Provider Scheduler */ class Service_Provider extends Base_Service_Provider { + /** + * Register the service provider. + */ + public function register(): void { + // Register the queue admin service provider. + if ( $this->app['config']->get( 'queue.enable_admin', true ) ) { + $this->app->register( Admin\Service_Provider::class ); + } + } + /** * Register the WordPress queue provider's post type and taxonomies. */