200 sites are queued to transients and processed in chunks by these handlers. * * @package MainWP\Dashboard */ namespace MainWP\Dashboard; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Class MainWP_Abilities_Cron * * Handles cron processing for batch Abilities API operations. * Processes jobs in chunks of 20 sites with timeout protection and reschedule logic. */ class MainWP_Abilities_Cron { //phpcs:ignore -- NOSONAR - multi methods. /** * Singleton instance. * * @var null|self */ private static $instance = null; /** * Get singleton instance. * * @return self */ public static function instance(): self { if ( null === static::$instance ) { static::$instance = new self(); } return static::$instance; } /** * Constructor. * * Private to enforce singleton pattern via instance(). * Registers cron action handlers for batch processing. */ private function __construct() { // Register cron handlers for batch job processing. add_action( 'mainwp_process_sync_job', array( $this, 'process_sync_job' ) ); add_action( 'mainwp_process_update_job', array( $this, 'process_update_job' ) ); add_action( 'mainwp_process_batch_job', array( $this, 'process_batch_job' ) ); } /** * Prevent cloning of the singleton instance. * * @return void */ private function __clone() {} /** * Prevent unserialization of the singleton instance. * * @return void * @throws \Exception Always throws to prevent unserialization. */ public function __wakeup() { throw new \Exception( 'Cannot unserialize singleton.' ); // NOSONAR - generic exception ok. } /** * Check if debug logging is enabled for cron handlers. * * Use the `mainwp_abilities_cron_debug_logging` filter to disable verbose * logging during cron processing. Defaults to true (logging enabled). * * @return bool True if debug logging is enabled, false otherwise. */ private function is_debug_logging_enabled(): bool { return (bool) apply_filters( 'mainwp_abilities_cron_debug_logging', true ); } /** * Log a debug message if debug logging is enabled. * * @param string $message Message to log. * @return void */ private function log_debug( string $message ): void { if ( $this->is_debug_logging_enabled() ) { MainWP_Logger::instance()->log_events( 'debug-abilities', $message ); } } /** * Process a batch sync job. * * Called via WP-Cron when a sync job is scheduled. * Processes sites in chunks, updates progress, and reschedules until complete. * * @param string $job_id Job ID to process. * @return void */ public function process_sync_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex function. // Environment setup for long-running process. ignore_user_abort( true ); MainWP_System_Utility::set_time_limit( 0 ); // Sanitize job_id for safe transient lookup and logging. $job_id = sanitize_key( (string) $job_id ); if ( empty( $job_id ) ) { $this->log_debug( 'Sync job ID is empty after sanitization' ); return; } // Load job from transient. $job = get_transient( 'mainwp_sync_job_' . $job_id ); if ( empty( $job ) || ! is_array( $job ) ) { $this->log_debug( 'Sync job not found or expired: ' . $job_id ); return; } // Timeout protection (4 hours max). if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) { $job['status'] = 'failed'; $job['completed'] = time(); $job['job_timed_out'] = true; $job['errors'][] = array( 'site_id' => 0, 'code' => 'timeout', 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ), ); set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( 'Sync job timed out: ' . $job_id ); return; } // Status transition: queued -> processing. if ( 'queued' === $job['status'] ) { $job['status'] = 'processing'; $job['started'] = time(); } // Chunk processing (default 20 sites per run). // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter. $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 ); if ( $chunk_size < 1 ) { $chunk_size = 1; } // Normalize result arrays defensively (transient data could be corrupted). $job['synced'] = isset( $job['synced'] ) && is_array( $job['synced'] ) ? $job['synced'] : array(); $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array(); // Calculate already processed site IDs. $processed_ids = array_merge( $job['synced'], array_column( $job['errors'], 'site_id' ) ); // Get remaining sites to process. $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); $chunk = array_slice( $remaining, 0, $chunk_size ); $this->log_debug( sprintf( 'Processing sync job %s: %d sites in chunk, %d remaining', $job_id, count( $chunk ), count( $remaining ) ) ); // Process each site in the chunk. foreach ( $chunk as $site_id ) { try { $website = MainWP_DB::instance()->get_website_by_id( $site_id ); if ( empty( $website ) ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'site_not_found', 'message' => __( 'Site not found.', 'mainwp' ), ); ++$job['processed']; continue; } // Call sync_site: false for force fetch, true for allow disconnect, false for clear session. $result = MainWP_Sync::sync_site( $website, false, true, false ); if ( true === $result ) { $job['synced'][] = $site_id; } else { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'sync_failed', 'message' => __( 'Sync returned false.', 'mainwp' ), ); } } catch ( \Exception $e ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'exception', 'message' => $e->getMessage(), ); } ++$job['processed']; $job['progress'] = $job['total'] > 0 ? round( ( $job['processed'] / $job['total'] ) * 100 ) : 0; } // Save progress. set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS ); // Recalculate remaining after processing. $processed_ids = array_merge( $job['synced'], array_column( $job['errors'], 'site_id' ) ); $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); // Reschedule or complete. if ( count( $remaining ) > 0 ) { // Schedule next chunk (30 second delay). if ( ! wp_next_scheduled( 'mainwp_process_sync_job', array( $job_id ) ) ) { wp_schedule_single_event( time() + 30, 'mainwp_process_sync_job', array( $job_id ) ); } $this->log_debug( sprintf( 'Sync job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) ) ); } else { // Job complete. $job['status'] = 'completed'; $job['completed'] = time(); $job['progress'] = 100; set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( sprintf( 'Sync job %s completed: %d synced, %d errors', $job_id, count( $job['synced'] ), count( $job['errors'] ) ) ); } } /** * Process a batch update job. * * Called via WP-Cron when an update job is scheduled. * Processes sites in chunks, applying updates by type, and reschedules until complete. * * @param string $job_id Job ID to process. * @return void */ public function process_update_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex function. // Environment setup for long-running process. ignore_user_abort( true ); MainWP_System_Utility::set_time_limit( 0 ); // Sanitize job_id for safe transient lookup and logging. $job_id = sanitize_key( (string) $job_id ); if ( empty( $job_id ) ) { $this->log_debug( 'Update job ID is empty after sanitization' ); return; } // Load job from transient. $job = get_transient( 'mainwp_update_job_' . $job_id ); if ( empty( $job ) || ! is_array( $job ) ) { $this->log_debug( 'Update job not found or expired: ' . $job_id ); return; } // Timeout protection (4 hours max). if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) { $job['status'] = 'failed'; $job['completed'] = time(); $job['job_timed_out'] = true; $job['errors'][] = array( 'site_id' => 0, 'code' => 'timeout', 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ), ); set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( 'Update job timed out: ' . $job_id ); return; } // Status transition: queued -> processing. if ( 'queued' === $job['status'] ) { $job['status'] = 'processing'; $job['started'] = time(); } // Chunk processing (default 20 sites per run). // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter. $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 ); if ( $chunk_size < 1 ) { $chunk_size = 1; } // Normalize result arrays defensively (transient data could be corrupted). $job['updated'] = isset( $job['updated'] ) && is_array( $job['updated'] ) ? $job['updated'] : array(); $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array(); // Calculate already processed site IDs. $processed_ids = array_merge( $job['updated'], array_column( $job['errors'], 'site_id' ) ); // Get remaining sites to process. $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); $chunk = array_slice( $remaining, 0, $chunk_size ); $this->log_debug( sprintf( 'Processing update job %s: %d sites in chunk, %d remaining, types: %s', $job_id, count( $chunk ), count( $remaining ), implode( ',', $job['types'] ) ) ); // Process each site in the chunk. foreach ( $chunk as $site_id ) { try { $website = MainWP_DB::instance()->get_website_by_id( $site_id ); if ( empty( $website ) ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'site_not_found', 'message' => __( 'Site not found.', 'mainwp' ), ); ++$job['processed']; continue; } $site_success = true; $site_errors = array(); // Normalize specific_items to avoid TypeError if malformed in transient data. $specific_items = isset( $job['specific_items'] ) && is_array( $job['specific_items'] ) ? $job['specific_items'] : array(); // Process each update type. foreach ( $job['types'] as $type ) { try { switch ( $type ) { case 'core': /** * Fires before WordPress core update action. * * @since 5.4 * * @param object $website Site object. */ do_action( 'mainwp_before_core_update', $website ); $information = MainWP_Connect::fetch_url_authed( $website, 'upgrade' ); // Validate response - check for transport failure or API error. if ( false === $information ) { $site_success = false; $site_errors[] = 'core: ' . __( 'Connection failed - no response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Core upgrade failed for site %d: transport failure (false response)', $website->id ) ); /** * Fires after WordPress core update action. * * @since 5.4 * * @param array|false $information Response from child site (false on failure). * @param object $website Site object. */ do_action( 'mainwp_after_core_update', $information, $website ); } elseif ( is_array( $information ) && isset( $information['error'] ) ) { $site_success = false; $site_errors[] = 'core: ' . $information['error']; $this->log_debug( sprintf( 'Core upgrade failed for site %d: %s', $website->id, $information['error'] ) ); /** This action is documented above in this switch case. */ do_action( 'mainwp_after_core_update', $information, $website ); } elseif ( ! is_array( $information ) ) { $site_success = false; $site_errors[] = 'core: ' . __( 'Invalid response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Core upgrade failed for site %d: invalid response type (%s), raw: %s', $website->id, gettype( $information ), is_scalar( $information ) ? $information : wp_json_encode( $information ) ) ); /** This action is documented above in this switch case. */ do_action( 'mainwp_after_core_update', $information, $website ); } else { // Success - fire after-action and sync. /** This action is documented above in this switch case. */ do_action( 'mainwp_after_core_update', $information, $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } break; case 'plugins': $slugs = $this->get_update_slugs_for_site( $website, 'plugin', $specific_items ); if ( ! empty( $slugs ) ) { $slugs_list = implode( ',', $slugs ); /** * Fires before plugin/theme/translation update actions. * * @since 4.1 * * @param string $type Update type: 'plugin', 'theme', or 'translation'. * @param string $list Comma-separated list of slugs being updated. * @param object $website Site object. */ do_action( 'mainwp_before_plugin_theme_translation_update', 'plugin', $slugs_list, $website ); $information = MainWP_Connect::fetch_url_authed( $website, 'upgradeplugintheme', array( 'type' => 'plugin', 'list' => urldecode( $slugs_list ), ) ); // Validate response - check for transport failure or API error. if ( false === $information ) { $site_success = false; $site_errors[] = 'plugins: ' . __( 'Connection failed - no response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Plugin upgrade failed for site %d (slugs: %s): transport failure (false response)', $website->id, $slugs_list ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'plugin', $slugs_list, $website ); } elseif ( is_array( $information ) && isset( $information['error'] ) ) { $site_success = false; $site_errors[] = 'plugins: ' . $information['error']; $this->log_debug( sprintf( 'Plugin upgrade failed for site %d (slugs: %s): %s', $website->id, $slugs_list, $information['error'] ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website ); } elseif ( ! is_array( $information ) ) { $site_success = false; $site_errors[] = 'plugins: ' . __( 'Invalid response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Plugin upgrade failed for site %d (slugs: %s): invalid response type (%s), raw: %s', $website->id, $slugs_list, gettype( $information ), is_scalar( $information ) ? $information : wp_json_encode( $information ) ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'plugin', $slugs_list, $website ); } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) { // Partial failure - some plugins failed to update. $error_msgs = array(); foreach ( $information['upgrades_error'] as $slug => $error_msg ) { $error_msgs[] = $slug . ': ' . $error_msg; } $site_success = false; $site_errors[] = 'plugins: ' . implode( '; ', $error_msgs ); $this->log_debug( sprintf( 'Plugin upgrade partial failure for site %d (slugs: %s): %s', $website->id, $slugs_list, implode( '; ', $error_msgs ) ) ); // Still fire after-action and sync for partial success. /** * Fires after plugin/theme/translation update actions. * * @since 4.1 * * @param array $information Response from child site. * @param string $type Update type: 'plugin', 'theme', or 'translation'. * @param string $list Comma-separated list of slugs that were updated. * @param object $website Site object. */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } else { // Success - fire after-action and sync. /** This action is documented above in this switch case. */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } } break; case 'themes': $slugs = $this->get_update_slugs_for_site( $website, 'theme', $specific_items ); if ( ! empty( $slugs ) ) { $slugs_list = implode( ',', $slugs ); /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_before_plugin_theme_translation_update', 'theme', $slugs_list, $website ); $information = MainWP_Connect::fetch_url_authed( $website, 'upgradeplugintheme', array( 'type' => 'theme', 'list' => urldecode( $slugs_list ), ) ); // Validate response - check for transport failure or API error. if ( false === $information ) { $site_success = false; $site_errors[] = 'themes: ' . __( 'Connection failed - no response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Theme upgrade failed for site %d (slugs: %s): transport failure (false response)', $website->id, $slugs_list ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'theme', $slugs_list, $website ); } elseif ( is_array( $information ) && isset( $information['error'] ) ) { $site_success = false; $site_errors[] = 'themes: ' . $information['error']; $this->log_debug( sprintf( 'Theme upgrade failed for site %d (slugs: %s): %s', $website->id, $slugs_list, $information['error'] ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website ); } elseif ( ! is_array( $information ) ) { $site_success = false; $site_errors[] = 'themes: ' . __( 'Invalid response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Theme upgrade failed for site %d (slugs: %s): invalid response type (%s), raw: %s', $website->id, $slugs_list, gettype( $information ), is_scalar( $information ) ? $information : wp_json_encode( $information ) ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'theme', $slugs_list, $website ); } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) { // Partial failure - some themes failed to update. $error_msgs = array(); foreach ( $information['upgrades_error'] as $slug => $error_msg ) { $error_msgs[] = $slug . ': ' . $error_msg; } $site_success = false; $site_errors[] = 'themes: ' . implode( '; ', $error_msgs ); $this->log_debug( sprintf( 'Theme upgrade partial failure for site %d (slugs: %s): %s', $website->id, $slugs_list, implode( '; ', $error_msgs ) ) ); // Still fire after-action and sync for partial success. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } else { // Success - fire after-action and sync. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } } break; case 'translations': /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_before_plugin_theme_translation_update', 'translation', '', $website ); $information = MainWP_Connect::fetch_url_authed( $website, 'upgradetranslation' ); // Validate response - check for transport failure or API error. if ( false === $information ) { $site_success = false; $site_errors[] = 'translations: ' . __( 'Connection failed - no response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Translation upgrade failed for site %d: transport failure (false response)', $website->id ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'translation', '', $website ); } elseif ( is_array( $information ) && isset( $information['error'] ) ) { $site_success = false; $site_errors[] = 'translations: ' . $information['error']; $this->log_debug( sprintf( 'Translation upgrade failed for site %d: %s', $website->id, $information['error'] ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website ); } elseif ( ! is_array( $information ) ) { $site_success = false; $site_errors[] = 'translations: ' . __( 'Invalid response from child site.', 'mainwp' ); $this->log_debug( sprintf( 'Translation upgrade failed for site %d: invalid response type (%s)', $website->id, gettype( $information ) ) ); // Fire after-action even on failure so hooks always run. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', null, 'translation', '', $website ); } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) { // Partial failure - some translations failed to update. $error_msgs = array(); foreach ( $information['upgrades_error'] as $slug => $error_msg ) { $error_msgs[] = $slug . ': ' . $error_msg; } $site_success = false; $site_errors[] = 'translations: ' . implode( '; ', $error_msgs ); $this->log_debug( sprintf( 'Translation upgrade partial failure for site %d: %s', $website->id, implode( '; ', $error_msgs ) ) ); // Still fire after-action and sync for partial success. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } else { // Success - fire after-action and sync. /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */ do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website ); // Sync site data immediately if child returned sync info. if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) { MainWP_Sync::sync_information_array( $website, $information['sync'] ); } } break; default: // do nothing for unknown type. break; } } catch ( \Exception $e ) { $site_success = false; $site_errors[] = $type . ': ' . $e->getMessage(); } } if ( $site_success && empty( $site_errors ) ) { $job['updated'][] = $site_id; } else { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'update_failed', 'message' => implode( '; ', $site_errors ), ); } } catch ( \Exception $e ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'exception', 'message' => $e->getMessage(), ); } ++$job['processed']; $job['progress'] = $job['total'] > 0 ? round( ( $job['processed'] / $job['total'] ) * 100 ) : 0; } // Save progress. set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS ); // Recalculate remaining after processing. $processed_ids = array_merge( $job['updated'], array_column( $job['errors'], 'site_id' ) ); $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); // Reschedule or complete. if ( count( $remaining ) > 0 ) { // Schedule next chunk (30 second delay). if ( ! wp_next_scheduled( 'mainwp_process_update_job', array( $job_id ) ) ) { wp_schedule_single_event( time() + 30, 'mainwp_process_update_job', array( $job_id ) ); } $this->log_debug( sprintf( 'Update job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) ) ); } else { // Job complete. $job['status'] = 'completed'; $job['completed'] = time(); $job['progress'] = 100; set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( sprintf( 'Update job %s completed: %d updated, %d errors', $job_id, count( $job['updated'] ), count( $job['errors'] ) ) ); } } /** * Get update slugs for a site, optionally filtered by specific items. * * @param object $website Site object. * @param string $type Type: 'plugin' or 'theme'. * @param array $specific_items Optional array of specific slugs to filter. * @return array Array of slugs to update. */ private function get_update_slugs_for_site( $website, string $type, array $specific_items = array() ): array { $slugs = array(); if ( 'plugin' === $type ) { $updates = json_decode( $website->plugin_upgrades, true ); } else { $updates = json_decode( $website->theme_upgrades, true ); } if ( ! is_array( $updates ) ) { $updates = array(); } foreach ( array_keys( $updates ) as $slug ) { // If specific items provided, filter to only those. if ( ! empty( $specific_items ) && ! in_array( $slug, $specific_items, true ) ) { continue; } $slugs[] = $slug; } return $slugs; } /** * Process a batch operation job (reconnect, disconnect, check, suspend). * * Called via WP-Cron when a batch operation is scheduled. * Processes sites in chunks and reschedules until complete. * * @param string $job_id Job ID to process. * @return void */ public function process_batch_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex method. // Environment setup for long-running process. ignore_user_abort( true ); MainWP_System_Utility::set_time_limit( 0 ); // Sanitize job_id for safe transient lookup and logging. $job_id = sanitize_key( (string) $job_id ); if ( empty( $job_id ) ) { $this->log_debug( 'Batch job ID is empty after sanitization' ); return; } // Load job from transient. $job = get_transient( 'mainwp_batch_job_' . $job_id ); if ( empty( $job ) || ! is_array( $job ) ) { $this->log_debug( 'Batch job not found or expired: ' . $job_id ); return; } // Timeout protection (4 hours max). if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) { $job['status'] = 'failed'; $job['completed'] = time(); $job['job_timed_out'] = true; $job['errors'][] = array( 'site_id' => 0, 'code' => 'timeout', 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ), ); set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( 'Batch job timed out: ' . $job_id ); return; } // Status transition: queued -> processing. if ( 'queued' === $job['status'] ) { $job['status'] = 'processing'; $job['started'] = time(); } // Chunk processing (default 20 sites per run). // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter. $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 ); if ( $chunk_size < 1 ) { $chunk_size = 1; } // Normalize result arrays defensively (transient data could be corrupted). $job['successful'] = isset( $job['successful'] ) && is_array( $job['successful'] ) ? $job['successful'] : array(); $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array(); // Calculate already processed site IDs. $processed_ids = array_merge( $job['successful'], array_column( $job['errors'], 'site_id' ) ); // Get remaining sites to process. $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); $chunk = array_slice( $remaining, 0, $chunk_size ); $this->log_debug( sprintf( 'Processing batch job %s (%s): %d sites in chunk, %d remaining', $job_id, $job['job_type'], count( $chunk ), count( $remaining ) ) ); // Process each site in the chunk. foreach ( $chunk as $site_id ) { try { $website = MainWP_DB::instance()->get_website_by_id( $site_id ); if ( empty( $website ) ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'site_not_found', 'message' => __( 'Site not found.', 'mainwp' ), ); ++$job['processed']; continue; } $success = false; $error = ''; // Execute operation based on job type. switch ( $job['job_type'] ) { case 'reconnect': try { $result = MainWP_Manage_Sites_View::m_reconnect_site( $website ); $success = (bool) $result; if ( ! $success ) { $error = __( 'Reconnect returned false.', 'mainwp' ); } } catch ( \Exception $e ) { $error = $e->getMessage(); } break; case 'disconnect': $result = MainWP_DB::instance()->update_website_sync_values( $website->id, array( 'sync_errors' => __( 'Manually disconnected', 'mainwp' ) ) ); // wpdb->update() returns int|false: rows affected or false on error. $success = ( false !== $result ); if ( ! $success ) { $error = __( 'Database update failed during disconnect.', 'mainwp' ); $this->log_debug( sprintf( 'Disconnect failed for site %d: database update returned false', $website->id ) ); } break; case 'check': $result = MainWP_Monitoring_Handler::handle_check_website( $website->id ); if ( is_wp_error( $result ) ) { $error = $result->get_error_message(); } else { $success = true; } break; case 'suspend': $result = MainWP_DB::instance()->update_website_values( $website->id, array( 'suspended' => 1 ) ); // wpdb->update() returns int|false: rows affected or false on error. $success = ( false !== $result ); if ( $success ) { /** * Fires when a site is suspended. * * @param object $website Site object. * @param int $status Suspension status (1 = suspended). */ do_action( 'mainwp_site_suspended', $website, 1 ); } else { $error = __( 'Database update failed during suspend.', 'mainwp' ); $this->log_debug( sprintf( 'Suspend failed for site %d: database update returned false', $website->id ) ); } break; default: $error = __( 'Unknown operation type.', 'mainwp' ); } if ( $success ) { $job['successful'][] = $site_id; } else { $job['errors'][] = array( 'site_id' => $site_id, 'code' => $job['job_type'] . '_failed', 'message' => $error, ); } } catch ( \Exception $e ) { $job['errors'][] = array( 'site_id' => $site_id, 'code' => 'exception', 'message' => $e->getMessage(), ); } ++$job['processed']; $job['progress'] = $job['total'] > 0 ? round( ( $job['processed'] / $job['total'] ) * 100 ) : 0; } // Save progress. set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS ); // Recalculate remaining after processing. $processed_ids = array_merge( $job['successful'], array_column( $job['errors'], 'site_id' ) ); $remaining = array_values( array_diff( $job['sites'], $processed_ids ) ); // Reschedule or complete. if ( count( $remaining ) > 0 ) { // Schedule next chunk (30 second delay). if ( ! wp_next_scheduled( 'mainwp_process_batch_job', array( $job_id ) ) ) { wp_schedule_single_event( time() + 30, 'mainwp_process_batch_job', array( $job_id ) ); } $this->log_debug( sprintf( 'Batch job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) ) ); } else { // Job complete. $job['status'] = 'completed'; $job['completed'] = time(); $job['progress'] = 100; set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS ); $this->log_debug( sprintf( 'Batch job %s (%s) completed: %d successful, %d errors', $job_id, $job['job_type'], count( $job['successful'] ), count( $job['errors'] ) ) ); } } }