namespace ImageOptimization\Modules\Optimization\Components;
use ImageOptimization\Classes\Async_Operation\{
Exceptions\Async_Operation_Exception,
Queries\Image_Optimization_Operation_Query,
use ImageOptimization\Classes\Image\{
Image_Optimization_Error_Type,
use ImageOptimization\Classes\Logger;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
const FIVE_MINUTES_IN_SECONDS = 300;
public function cleanup_stuck_operations() {
$table_name = $wpdb->prefix . 'actionscheduler_actions';
$threshold = $now - self::FIVE_MINUTES_IN_SECONDS;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_col(
WHERE last_attempt_gmt IS NOT NULL
AND UNIX_TIMESTAMP(last_attempt_gmt) < %d
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $results ) ) {
Logger::debug( 'No stuck optimization operations found for cleanup.' );
foreach ( $results as $action_id ) {
$action = Async_Operation::get_by_id( (int) $action_id );
Async_Operation_Queue::OPTIMIZE !== $action->get_queue() ||
Async_Operation::OPERATION_STATUS_RUNNING !== $action->get_status()
'action_scheduler_failed_action',
self::FIVE_MINUTES_IN_SECONDS
Logger::debug( "Triggered retry for stuck action ID {$action_id}." );
} catch ( Throwable $t ) {
Logger::warn( "Failed to handle stuck operation for action ID {$action_id}: " . $t->getMessage() );
$this->cleanup_stuck_statuses();
} catch ( Throwable $t ) {
Logger::warn( 'Failed to run stuck statuses clearing job: ' . $t->getMessage() );
* The handler checks if there are any attachments that have the in-progress status, but no jobs are currently
* run or pending. Those attachment statuses will be updated to a generic error.
* @throws Async_Operation_Exception
public function cleanup_stuck_statuses() {
$operations_query = ( new Image_Optimization_Operation_Query() )
->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
$operations = Async_Operation::get( $operations_query );
if ( ! empty( $operations ) ) {
$image_query = ( new Image_Query_Builder() )
->return_optimization_in_progress_images()
foreach ( $image_query->posts as $attachment_id ) {
( new Image_Meta( $attachment_id ) )
->set_status( Image_Status::OPTIMIZATION_FAILED )
->set_error_type( Image_Optimization_Error_Type::GENERIC )
public function schedule_cleanup() {
$cleanup_job_query = ( new Operation_Query() )
->set_queue( Async_Operation_Queue::CLEANUP )
->set_hook( Async_Operation_Hook::STUCK_OPERATION_CLEANUP )
->set_status( [ Async_Operation::OPERATION_STATUS_PENDING, Async_Operation::OPERATION_STATUS_RUNNING ] )
// Prevents job duplication. For some reason unique=true is not enough
if ( ! empty( Async_Operation::get( $cleanup_job_query ) ) ) {
Async_Operation::create_recurring(
self::FIVE_MINUTES_IN_SECONDS,
Async_Operation_Hook::STUCK_OPERATION_CLEANUP,
Async_Operation_Queue::CLEANUP,
} catch ( Async_Operation_Exception $aoe ) {
Logger::warn( 'Failed to schedule recurring stuck operation cleanup: ' . $aoe->getMessage() );
public function __construct() {
add_action( 'action_scheduler_init', [ $this, 'schedule_cleanup' ] );
add_action( Async_Operation_Hook::STUCK_OPERATION_CLEANUP, [ $this, 'cleanup_stuck_operations' ] );