PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.2
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.2
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / includes / abilities / class-mainwp-abilities-cron.php

class-mainwp-abilities-cron.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.2, at includes/abilities/class-mainwp-abilities-cron.php

1,058 lines 51.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Abilities Cron Handlers
4 *
5 * Processes batch operations (sync, update, batch) queued via the Abilities API.
6 * Jobs >200 sites are queued to transients and processed in chunks by these handlers.
7 *
8 * @package MainWP\Dashboard
9 */
10
11 namespace MainWP\Dashboard;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class MainWP_Abilities_Cron
19 *
20 * Handles cron processing for batch Abilities API operations.
21 * Processes jobs in chunks of 20 sites with timeout protection and reschedule logic.
22 */
23 class MainWP_Abilities_Cron { //phpcs:ignore -- NOSONAR - multi methods.
24
25 /**
26 * Singleton instance.
27 *
28 * @var null|self
29 */
30 private static $instance = null;
31
32 /**
33 * Get singleton instance.
34 *
35 * @return self
36 */
37 public static function instance(): self {
38 if ( null === static::$instance ) {
39 static::$instance = new self();
40 }
41 return static::$instance;
42 }
43
44 /**
45 * Constructor.
46 *
47 * Private to enforce singleton pattern via instance().
48 * Registers cron action handlers for batch processing.
49 */
50 private function __construct() {
51 // Register cron handlers for batch job processing.
52 add_action( 'mainwp_process_sync_job', array( $this, 'process_sync_job' ) );
53 add_action( 'mainwp_process_update_job', array( $this, 'process_update_job' ) );
54 add_action( 'mainwp_process_batch_job', array( $this, 'process_batch_job' ) );
55 }
56
57 /**
58 * Prevent cloning of the singleton instance.
59 *
60 * @return void
61 */
62 private function __clone() {}
63
64 /**
65 * Prevent unserialization of the singleton instance.
66 *
67 * @return void
68 * @throws \Exception Always throws to prevent unserialization.
69 */
70 public function __wakeup() {
71 throw new \Exception( 'Cannot unserialize singleton.' ); // NOSONAR - generic exception ok.
72 }
73
74 /**
75 * Check if debug logging is enabled for cron handlers.
76 *
77 * Use the `mainwp_abilities_cron_debug_logging` filter to disable verbose
78 * logging during cron processing. Defaults to true (logging enabled).
79 *
80 * @return bool True if debug logging is enabled, false otherwise.
81 */
82 private function is_debug_logging_enabled(): bool {
83 return (bool) apply_filters( 'mainwp_abilities_cron_debug_logging', true );
84 }
85
86 /**
87 * Log a debug message if debug logging is enabled.
88 *
89 * @param string $message Message to log.
90 * @return void
91 */
92 private function log_debug( string $message ): void {
93 if ( $this->is_debug_logging_enabled() ) {
94 MainWP_Logger::instance()->log_events( 'debug-abilities', $message );
95 }
96 }
97
98 /**
99 * Process a batch sync job.
100 *
101 * Called via WP-Cron when a sync job is scheduled.
102 * Processes sites in chunks, updates progress, and reschedules until complete.
103 *
104 * @param string $job_id Job ID to process.
105 * @return void
106 */
107 public function process_sync_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex function.
108 // Environment setup for long-running process.
109 ignore_user_abort( true );
110 MainWP_System_Utility::set_time_limit( 0 );
111
112 // Sanitize job_id for safe transient lookup and logging.
113 $job_id = sanitize_key( (string) $job_id );
114 if ( empty( $job_id ) ) {
115 $this->log_debug( 'Sync job ID is empty after sanitization' );
116 return;
117 }
118
119 // Load job from transient.
120 $job = get_transient( 'mainwp_sync_job_' . $job_id );
121 if ( empty( $job ) || ! is_array( $job ) ) {
122 $this->log_debug( 'Sync job not found or expired: ' . $job_id );
123 return;
124 }
125
126 // Timeout protection (4 hours max).
127 if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) {
128 $job['status'] = 'failed';
129 $job['completed'] = time();
130 $job['job_timed_out'] = true;
131 $job['errors'][] = array(
132 'site_id' => 0,
133 'code' => 'timeout',
134 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ),
135 );
136 set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS );
137 $this->log_debug( 'Sync job timed out: ' . $job_id );
138 return;
139 }
140
141 // Status transition: queued -> processing.
142 if ( 'queued' === $job['status'] ) {
143 $job['status'] = 'processing';
144 $job['started'] = time();
145 }
146
147 // Chunk processing (default 20 sites per run).
148 // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter.
149 $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 );
150 if ( $chunk_size < 1 ) {
151 $chunk_size = 1;
152 }
153
154 // Normalize result arrays defensively (transient data could be corrupted).
155 $job['synced'] = isset( $job['synced'] ) && is_array( $job['synced'] ) ? $job['synced'] : array();
156 $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array();
157
158 // Calculate already processed site IDs.
159 $processed_ids = array_merge(
160 $job['synced'],
161 array_column( $job['errors'], 'site_id' )
162 );
163
164 // Get remaining sites to process.
165 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
166 $chunk = array_slice( $remaining, 0, $chunk_size );
167
168 $this->log_debug(
169 sprintf(
170 'Processing sync job %s: %d sites in chunk, %d remaining',
171 $job_id,
172 count( $chunk ),
173 count( $remaining )
174 )
175 );
176
177 // Process each site in the chunk.
178 foreach ( $chunk as $site_id ) {
179 try {
180 $website = MainWP_DB::instance()->get_website_by_id( $site_id );
181
182 if ( empty( $website ) ) {
183 $job['errors'][] = array(
184 'site_id' => $site_id,
185 'code' => 'site_not_found',
186 'message' => __( 'Site not found.', 'mainwp' ),
187 );
188 ++$job['processed'];
189 continue;
190 }
191
192 // Call sync_site: false for force fetch, true for allow disconnect, false for clear session.
193 $result = MainWP_Sync::sync_site( $website, false, true, false );
194
195 if ( true === $result ) {
196 $job['synced'][] = $site_id;
197 } else {
198 $job['errors'][] = array(
199 'site_id' => $site_id,
200 'code' => 'sync_failed',
201 'message' => __( 'Sync returned false.', 'mainwp' ),
202 );
203 }
204 } catch ( \Exception $e ) {
205 $job['errors'][] = array(
206 'site_id' => $site_id,
207 'code' => 'exception',
208 'message' => $e->getMessage(),
209 );
210 }
211
212 ++$job['processed'];
213 $job['progress'] = $job['total'] > 0
214 ? round( ( $job['processed'] / $job['total'] ) * 100 )
215 : 0;
216 }
217
218 // Save progress.
219 set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS );
220
221 // Recalculate remaining after processing.
222 $processed_ids = array_merge(
223 $job['synced'],
224 array_column( $job['errors'], 'site_id' )
225 );
226 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
227
228 // Reschedule or complete.
229 if ( count( $remaining ) > 0 ) {
230 // Schedule next chunk (30 second delay).
231 if ( ! wp_next_scheduled( 'mainwp_process_sync_job', array( $job_id ) ) ) {
232 wp_schedule_single_event( time() + 30, 'mainwp_process_sync_job', array( $job_id ) );
233 }
234 $this->log_debug(
235 sprintf( 'Sync job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) )
236 );
237 } else {
238 // Job complete.
239 $job['status'] = 'completed';
240 $job['completed'] = time();
241 $job['progress'] = 100;
242 set_transient( 'mainwp_sync_job_' . $job_id, $job, DAY_IN_SECONDS );
243 $this->log_debug(
244 sprintf(
245 'Sync job %s completed: %d synced, %d errors',
246 $job_id,
247 count( $job['synced'] ),
248 count( $job['errors'] )
249 )
250 );
251 }
252 }
253
254 /**
255 * Process a batch update job.
256 *
257 * Called via WP-Cron when an update job is scheduled.
258 * Processes sites in chunks, applying updates by type, and reschedules until complete.
259 *
260 * @param string $job_id Job ID to process.
261 * @return void
262 */
263 public function process_update_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex function.
264 // Environment setup for long-running process.
265 ignore_user_abort( true );
266 MainWP_System_Utility::set_time_limit( 0 );
267
268 // Sanitize job_id for safe transient lookup and logging.
269 $job_id = sanitize_key( (string) $job_id );
270 if ( empty( $job_id ) ) {
271 $this->log_debug( 'Update job ID is empty after sanitization' );
272 return;
273 }
274
275 // Load job from transient.
276 $job = get_transient( 'mainwp_update_job_' . $job_id );
277 if ( empty( $job ) || ! is_array( $job ) ) {
278 $this->log_debug( 'Update job not found or expired: ' . $job_id );
279 return;
280 }
281
282 // Timeout protection (4 hours max).
283 if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) {
284 $job['status'] = 'failed';
285 $job['completed'] = time();
286 $job['job_timed_out'] = true;
287 $job['errors'][] = array(
288 'site_id' => 0,
289 'code' => 'timeout',
290 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ),
291 );
292 set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS );
293 $this->log_debug( 'Update job timed out: ' . $job_id );
294 return;
295 }
296
297 // Status transition: queued -> processing.
298 if ( 'queued' === $job['status'] ) {
299 $job['status'] = 'processing';
300 $job['started'] = time();
301 }
302
303 // Chunk processing (default 20 sites per run).
304 // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter.
305 $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 );
306 if ( $chunk_size < 1 ) {
307 $chunk_size = 1;
308 }
309
310 // Normalize result arrays defensively (transient data could be corrupted).
311 $job['updated'] = isset( $job['updated'] ) && is_array( $job['updated'] ) ? $job['updated'] : array();
312 $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array();
313
314 // Calculate already processed site IDs.
315 $processed_ids = array_merge(
316 $job['updated'],
317 array_column( $job['errors'], 'site_id' )
318 );
319
320 // Get remaining sites to process.
321 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
322 $chunk = array_slice( $remaining, 0, $chunk_size );
323
324 $this->log_debug(
325 sprintf(
326 'Processing update job %s: %d sites in chunk, %d remaining, types: %s',
327 $job_id,
328 count( $chunk ),
329 count( $remaining ),
330 implode( ',', $job['types'] )
331 )
332 );
333
334 // Process each site in the chunk.
335 foreach ( $chunk as $site_id ) {
336 try {
337 $website = MainWP_DB::instance()->get_website_by_id( $site_id );
338
339 if ( empty( $website ) ) {
340 $job['errors'][] = array(
341 'site_id' => $site_id,
342 'code' => 'site_not_found',
343 'message' => __( 'Site not found.', 'mainwp' ),
344 );
345 ++$job['processed'];
346 continue;
347 }
348
349 $site_success = true;
350 $site_errors = array();
351
352 // Normalize specific_items to avoid TypeError if malformed in transient data.
353 $specific_items = isset( $job['specific_items'] ) && is_array( $job['specific_items'] )
354 ? $job['specific_items']
355 : array();
356
357 // Process each update type.
358 foreach ( $job['types'] as $type ) {
359 try {
360 switch ( $type ) {
361 case 'core':
362 /**
363 * Fires before WordPress core update action.
364 *
365 * @since 5.4
366 *
367 * @param object $website Site object.
368 */
369 do_action( 'mainwp_before_core_update', $website );
370
371 $information = MainWP_Connect::fetch_url_authed( $website, 'upgrade' );
372
373 // Validate response - check for transport failure or API error.
374 if ( false === $information ) {
375 $site_success = false;
376 $site_errors[] = 'core: ' . __( 'Connection failed - no response from child site.', 'mainwp' );
377 $this->log_debug(
378 sprintf(
379 'Core upgrade failed for site %d: transport failure (false response)',
380 $website->id
381 )
382 );
383
384 /**
385 * Fires after WordPress core update action.
386 *
387 * @since 5.4
388 *
389 * @param array|false $information Response from child site (false on failure).
390 * @param object $website Site object.
391 */
392 do_action( 'mainwp_after_core_update', $information, $website );
393 } elseif ( is_array( $information ) && isset( $information['error'] ) ) {
394 $site_success = false;
395 $site_errors[] = 'core: ' . $information['error'];
396 $this->log_debug(
397 sprintf(
398 'Core upgrade failed for site %d: %s',
399 $website->id,
400 $information['error']
401 )
402 );
403
404 /** This action is documented above in this switch case. */
405 do_action( 'mainwp_after_core_update', $information, $website );
406 } elseif ( ! is_array( $information ) ) {
407 $site_success = false;
408 $site_errors[] = 'core: ' . __( 'Invalid response from child site.', 'mainwp' );
409 $this->log_debug(
410 sprintf(
411 'Core upgrade failed for site %d: invalid response type (%s), raw: %s',
412 $website->id,
413 gettype( $information ),
414 is_scalar( $information ) ? $information : wp_json_encode( $information )
415 )
416 );
417
418 /** This action is documented above in this switch case. */
419 do_action( 'mainwp_after_core_update', $information, $website );
420 } else {
421 // Success - fire after-action and sync.
422 /** This action is documented above in this switch case. */
423 do_action( 'mainwp_after_core_update', $information, $website );
424
425 // Sync site data immediately if child returned sync info.
426 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
427 MainWP_Sync::sync_information_array( $website, $information['sync'] );
428 }
429 }
430 break;
431
432 case 'plugins':
433 $slugs = $this->get_update_slugs_for_site( $website, 'plugin', $specific_items );
434 if ( ! empty( $slugs ) ) {
435 $slugs_list = implode( ',', $slugs );
436
437 /**
438 * Fires before plugin/theme/translation update actions.
439 *
440 * @since 4.1
441 *
442 * @param string $type Update type: 'plugin', 'theme', or 'translation'.
443 * @param string $list Comma-separated list of slugs being updated.
444 * @param object $website Site object.
445 */
446 do_action( 'mainwp_before_plugin_theme_translation_update', 'plugin', $slugs_list, $website );
447
448 $information = MainWP_Connect::fetch_url_authed(
449 $website,
450 'upgradeplugintheme',
451 array(
452 'type' => 'plugin',
453 'list' => urldecode( $slugs_list ),
454 )
455 );
456
457 // Validate response - check for transport failure or API error.
458 if ( false === $information ) {
459 $site_success = false;
460 $site_errors[] = 'plugins: ' . __( 'Connection failed - no response from child site.', 'mainwp' );
461 $this->log_debug(
462 sprintf(
463 'Plugin upgrade failed for site %d (slugs: %s): transport failure (false response)',
464 $website->id,
465 $slugs_list
466 )
467 );
468
469 // Fire after-action even on failure so hooks always run.
470 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
471 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'plugin', $slugs_list, $website );
472 } elseif ( is_array( $information ) && isset( $information['error'] ) ) {
473 $site_success = false;
474 $site_errors[] = 'plugins: ' . $information['error'];
475 $this->log_debug(
476 sprintf(
477 'Plugin upgrade failed for site %d (slugs: %s): %s',
478 $website->id,
479 $slugs_list,
480 $information['error']
481 )
482 );
483
484 // Fire after-action even on failure so hooks always run.
485 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
486 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website );
487 } elseif ( ! is_array( $information ) ) {
488 $site_success = false;
489 $site_errors[] = 'plugins: ' . __( 'Invalid response from child site.', 'mainwp' );
490 $this->log_debug(
491 sprintf(
492 'Plugin upgrade failed for site %d (slugs: %s): invalid response type (%s), raw: %s',
493 $website->id,
494 $slugs_list,
495 gettype( $information ),
496 is_scalar( $information ) ? $information : wp_json_encode( $information )
497 )
498 );
499
500 // Fire after-action even on failure so hooks always run.
501 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
502 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'plugin', $slugs_list, $website );
503 } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) {
504 // Partial failure - some plugins failed to update.
505 $error_msgs = array();
506 foreach ( $information['upgrades_error'] as $slug => $error_msg ) {
507 $error_msgs[] = $slug . ': ' . $error_msg;
508 }
509 $site_success = false;
510 $site_errors[] = 'plugins: ' . implode( '; ', $error_msgs );
511 $this->log_debug(
512 sprintf(
513 'Plugin upgrade partial failure for site %d (slugs: %s): %s',
514 $website->id,
515 $slugs_list,
516 implode( '; ', $error_msgs )
517 )
518 );
519
520 // Still fire after-action and sync for partial success.
521 /**
522 * Fires after plugin/theme/translation update actions.
523 *
524 * @since 4.1
525 *
526 * @param array $information Response from child site.
527 * @param string $type Update type: 'plugin', 'theme', or 'translation'.
528 * @param string $list Comma-separated list of slugs that were updated.
529 * @param object $website Site object.
530 */
531 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website );
532
533 // Sync site data immediately if child returned sync info.
534 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
535 MainWP_Sync::sync_information_array( $website, $information['sync'] );
536 }
537 } else {
538 // Success - fire after-action and sync.
539 /** This action is documented above in this switch case. */
540 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'plugin', $slugs_list, $website );
541
542 // Sync site data immediately if child returned sync info.
543 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
544 MainWP_Sync::sync_information_array( $website, $information['sync'] );
545 }
546 }
547 }
548 break;
549
550 case 'themes':
551 $slugs = $this->get_update_slugs_for_site( $website, 'theme', $specific_items );
552 if ( ! empty( $slugs ) ) {
553 $slugs_list = implode( ',', $slugs );
554
555 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
556 do_action( 'mainwp_before_plugin_theme_translation_update', 'theme', $slugs_list, $website );
557
558 $information = MainWP_Connect::fetch_url_authed(
559 $website,
560 'upgradeplugintheme',
561 array(
562 'type' => 'theme',
563 'list' => urldecode( $slugs_list ),
564 )
565 );
566
567 // Validate response - check for transport failure or API error.
568 if ( false === $information ) {
569 $site_success = false;
570 $site_errors[] = 'themes: ' . __( 'Connection failed - no response from child site.', 'mainwp' );
571 $this->log_debug(
572 sprintf(
573 'Theme upgrade failed for site %d (slugs: %s): transport failure (false response)',
574 $website->id,
575 $slugs_list
576 )
577 );
578
579 // Fire after-action even on failure so hooks always run.
580 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
581 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'theme', $slugs_list, $website );
582 } elseif ( is_array( $information ) && isset( $information['error'] ) ) {
583 $site_success = false;
584 $site_errors[] = 'themes: ' . $information['error'];
585 $this->log_debug(
586 sprintf(
587 'Theme upgrade failed for site %d (slugs: %s): %s',
588 $website->id,
589 $slugs_list,
590 $information['error']
591 )
592 );
593
594 // Fire after-action even on failure so hooks always run.
595 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
596 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website );
597 } elseif ( ! is_array( $information ) ) {
598 $site_success = false;
599 $site_errors[] = 'themes: ' . __( 'Invalid response from child site.', 'mainwp' );
600 $this->log_debug(
601 sprintf(
602 'Theme upgrade failed for site %d (slugs: %s): invalid response type (%s), raw: %s',
603 $website->id,
604 $slugs_list,
605 gettype( $information ),
606 is_scalar( $information ) ? $information : wp_json_encode( $information )
607 )
608 );
609
610 // Fire after-action even on failure so hooks always run.
611 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
612 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'theme', $slugs_list, $website );
613 } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) {
614 // Partial failure - some themes failed to update.
615 $error_msgs = array();
616 foreach ( $information['upgrades_error'] as $slug => $error_msg ) {
617 $error_msgs[] = $slug . ': ' . $error_msg;
618 }
619 $site_success = false;
620 $site_errors[] = 'themes: ' . implode( '; ', $error_msgs );
621 $this->log_debug(
622 sprintf(
623 'Theme upgrade partial failure for site %d (slugs: %s): %s',
624 $website->id,
625 $slugs_list,
626 implode( '; ', $error_msgs )
627 )
628 );
629
630 // Still fire after-action and sync for partial success.
631 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
632 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website );
633
634 // Sync site data immediately if child returned sync info.
635 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
636 MainWP_Sync::sync_information_array( $website, $information['sync'] );
637 }
638 } else {
639 // Success - fire after-action and sync.
640 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
641 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'theme', $slugs_list, $website );
642
643 // Sync site data immediately if child returned sync info.
644 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
645 MainWP_Sync::sync_information_array( $website, $information['sync'] );
646 }
647 }
648 }
649 break;
650
651 case 'translations':
652 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
653 do_action( 'mainwp_before_plugin_theme_translation_update', 'translation', '', $website );
654
655 $information = MainWP_Connect::fetch_url_authed( $website, 'upgradetranslation' );
656
657 // Validate response - check for transport failure or API error.
658 if ( false === $information ) {
659 $site_success = false;
660 $site_errors[] = 'translations: ' . __( 'Connection failed - no response from child site.', 'mainwp' );
661 $this->log_debug(
662 sprintf(
663 'Translation upgrade failed for site %d: transport failure (false response)',
664 $website->id
665 )
666 );
667
668 // Fire after-action even on failure so hooks always run.
669 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
670 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'translation', '', $website );
671 } elseif ( is_array( $information ) && isset( $information['error'] ) ) {
672 $site_success = false;
673 $site_errors[] = 'translations: ' . $information['error'];
674 $this->log_debug(
675 sprintf(
676 'Translation upgrade failed for site %d: %s',
677 $website->id,
678 $information['error']
679 )
680 );
681
682 // Fire after-action even on failure so hooks always run.
683 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
684 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website );
685 } elseif ( ! is_array( $information ) ) {
686 $site_success = false;
687 $site_errors[] = 'translations: ' . __( 'Invalid response from child site.', 'mainwp' );
688 $this->log_debug(
689 sprintf(
690 'Translation upgrade failed for site %d: invalid response type (%s)',
691 $website->id,
692 gettype( $information )
693 )
694 );
695
696 // Fire after-action even on failure so hooks always run.
697 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
698 do_action( 'mainwp_after_plugin_theme_translation_update', null, 'translation', '', $website );
699 } elseif ( isset( $information['upgrades_error'] ) && ! empty( $information['upgrades_error'] ) ) {
700 // Partial failure - some translations failed to update.
701 $error_msgs = array();
702 foreach ( $information['upgrades_error'] as $slug => $error_msg ) {
703 $error_msgs[] = $slug . ': ' . $error_msg;
704 }
705 $site_success = false;
706 $site_errors[] = 'translations: ' . implode( '; ', $error_msgs );
707 $this->log_debug(
708 sprintf(
709 'Translation upgrade partial failure for site %d: %s',
710 $website->id,
711 implode( '; ', $error_msgs )
712 )
713 );
714
715 // Still fire after-action and sync for partial success.
716 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
717 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website );
718
719 // Sync site data immediately if child returned sync info.
720 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
721 MainWP_Sync::sync_information_array( $website, $information['sync'] );
722 }
723 } else {
724 // Success - fire after-action and sync.
725 /** This action is documented in includes/abilities/class-mainwp-abilities-cron.php */
726 do_action( 'mainwp_after_plugin_theme_translation_update', $information, 'translation', '', $website );
727
728 // Sync site data immediately if child returned sync info.
729 if ( isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
730 MainWP_Sync::sync_information_array( $website, $information['sync'] );
731 }
732 }
733 break;
734 default:
735 // do nothing for unknown type.
736 break;
737 }
738 } catch ( \Exception $e ) {
739 $site_success = false;
740 $site_errors[] = $type . ': ' . $e->getMessage();
741 }
742 }
743
744 if ( $site_success && empty( $site_errors ) ) {
745 $job['updated'][] = $site_id;
746 } else {
747 $job['errors'][] = array(
748 'site_id' => $site_id,
749 'code' => 'update_failed',
750 'message' => implode( '; ', $site_errors ),
751 );
752 }
753 } catch ( \Exception $e ) {
754 $job['errors'][] = array(
755 'site_id' => $site_id,
756 'code' => 'exception',
757 'message' => $e->getMessage(),
758 );
759 }
760
761 ++$job['processed'];
762 $job['progress'] = $job['total'] > 0
763 ? round( ( $job['processed'] / $job['total'] ) * 100 )
764 : 0;
765 }
766
767 // Save progress.
768 set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS );
769
770 // Recalculate remaining after processing.
771 $processed_ids = array_merge(
772 $job['updated'],
773 array_column( $job['errors'], 'site_id' )
774 );
775 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
776
777 // Reschedule or complete.
778 if ( count( $remaining ) > 0 ) {
779 // Schedule next chunk (30 second delay).
780 if ( ! wp_next_scheduled( 'mainwp_process_update_job', array( $job_id ) ) ) {
781 wp_schedule_single_event( time() + 30, 'mainwp_process_update_job', array( $job_id ) );
782 }
783 $this->log_debug(
784 sprintf( 'Update job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) )
785 );
786 } else {
787 // Job complete.
788 $job['status'] = 'completed';
789 $job['completed'] = time();
790 $job['progress'] = 100;
791 set_transient( 'mainwp_update_job_' . $job_id, $job, DAY_IN_SECONDS );
792 $this->log_debug(
793 sprintf(
794 'Update job %s completed: %d updated, %d errors',
795 $job_id,
796 count( $job['updated'] ),
797 count( $job['errors'] )
798 )
799 );
800 }
801 }
802
803 /**
804 * Get update slugs for a site, optionally filtered by specific items.
805 *
806 * @param object $website Site object.
807 * @param string $type Type: 'plugin' or 'theme'.
808 * @param array $specific_items Optional array of specific slugs to filter.
809 * @return array Array of slugs to update.
810 */
811 private function get_update_slugs_for_site( $website, string $type, array $specific_items = array() ): array {
812 $slugs = array();
813
814 if ( 'plugin' === $type ) {
815 $updates = json_decode( $website->plugin_upgrades, true );
816 } else {
817 $updates = json_decode( $website->theme_upgrades, true );
818 }
819
820 if ( ! is_array( $updates ) ) {
821 $updates = array();
822 }
823
824 foreach ( array_keys( $updates ) as $slug ) {
825 // If specific items provided, filter to only those.
826 if ( ! empty( $specific_items ) && ! in_array( $slug, $specific_items, true ) ) {
827 continue;
828 }
829 $slugs[] = $slug;
830 }
831
832 return $slugs;
833 }
834
835 /**
836 * Process a batch operation job (reconnect, disconnect, check, suspend).
837 *
838 * Called via WP-Cron when a batch operation is scheduled.
839 * Processes sites in chunks and reschedules until complete.
840 *
841 * @param string $job_id Job ID to process.
842 * @return void
843 */
844 public function process_batch_job( $job_id ): void { // phpcs:ignore -- NOSONAR - complex method.
845 // Environment setup for long-running process.
846 ignore_user_abort( true );
847 MainWP_System_Utility::set_time_limit( 0 );
848
849 // Sanitize job_id for safe transient lookup and logging.
850 $job_id = sanitize_key( (string) $job_id );
851 if ( empty( $job_id ) ) {
852 $this->log_debug( 'Batch job ID is empty after sanitization' );
853 return;
854 }
855
856 // Load job from transient.
857 $job = get_transient( 'mainwp_batch_job_' . $job_id );
858 if ( empty( $job ) || ! is_array( $job ) ) {
859 $this->log_debug( 'Batch job not found or expired: ' . $job_id );
860 return;
861 }
862
863 // Timeout protection (4 hours max).
864 if ( ! empty( $job['started'] ) && time() > $job['started'] + 4 * HOUR_IN_SECONDS ) {
865 $job['status'] = 'failed';
866 $job['completed'] = time();
867 $job['job_timed_out'] = true;
868 $job['errors'][] = array(
869 'site_id' => 0,
870 'code' => 'timeout',
871 'message' => __( 'Job timed out after 4 hours.', 'mainwp' ),
872 );
873 set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS );
874 $this->log_debug( 'Batch job timed out: ' . $job_id );
875 return;
876 }
877
878 // Status transition: queued -> processing.
879 if ( 'queued' === $job['status'] ) {
880 $job['status'] = 'processing';
881 $job['started'] = time();
882 }
883
884 // Chunk processing (default 20 sites per run).
885 // Cast to int and clamp to minimum of 1 to prevent infinite loop from misconfigured filter.
886 $chunk_size = (int) apply_filters( 'mainwp_abilities_cron_chunk_size', 20 );
887 if ( $chunk_size < 1 ) {
888 $chunk_size = 1;
889 }
890
891 // Normalize result arrays defensively (transient data could be corrupted).
892 $job['successful'] = isset( $job['successful'] ) && is_array( $job['successful'] ) ? $job['successful'] : array();
893 $job['errors'] = isset( $job['errors'] ) && is_array( $job['errors'] ) ? $job['errors'] : array();
894
895 // Calculate already processed site IDs.
896 $processed_ids = array_merge(
897 $job['successful'],
898 array_column( $job['errors'], 'site_id' )
899 );
900
901 // Get remaining sites to process.
902 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
903 $chunk = array_slice( $remaining, 0, $chunk_size );
904
905 $this->log_debug(
906 sprintf(
907 'Processing batch job %s (%s): %d sites in chunk, %d remaining',
908 $job_id,
909 $job['job_type'],
910 count( $chunk ),
911 count( $remaining )
912 )
913 );
914
915 // Process each site in the chunk.
916 foreach ( $chunk as $site_id ) {
917 try {
918 $website = MainWP_DB::instance()->get_website_by_id( $site_id );
919
920 if ( empty( $website ) ) {
921 $job['errors'][] = array(
922 'site_id' => $site_id,
923 'code' => 'site_not_found',
924 'message' => __( 'Site not found.', 'mainwp' ),
925 );
926 ++$job['processed'];
927 continue;
928 }
929
930 $success = false;
931 $error = '';
932
933 // Execute operation based on job type.
934 switch ( $job['job_type'] ) {
935 case 'reconnect':
936 try {
937 $result = MainWP_Manage_Sites_View::m_reconnect_site( $website );
938 $success = (bool) $result;
939 if ( ! $success ) {
940 $error = __( 'Reconnect returned false.', 'mainwp' );
941 }
942 } catch ( \Exception $e ) {
943 $error = $e->getMessage();
944 }
945 break;
946
947 case 'disconnect':
948 $result = MainWP_DB::instance()->update_website_sync_values(
949 $website->id,
950 array( 'sync_errors' => __( 'Manually disconnected', 'mainwp' ) )
951 );
952 // wpdb->update() returns int|false: rows affected or false on error.
953 $success = ( false !== $result );
954 if ( ! $success ) {
955 $error = __( 'Database update failed during disconnect.', 'mainwp' );
956 $this->log_debug(
957 sprintf( 'Disconnect failed for site %d: database update returned false', $website->id )
958 );
959 }
960 break;
961
962 case 'check':
963 $result = MainWP_Monitoring_Handler::handle_check_website( $website->id );
964 if ( is_wp_error( $result ) ) {
965 $error = $result->get_error_message();
966 } else {
967 $success = true;
968 }
969 break;
970
971 case 'suspend':
972 $result = MainWP_DB::instance()->update_website_values(
973 $website->id,
974 array( 'suspended' => 1 )
975 );
976 // wpdb->update() returns int|false: rows affected or false on error.
977 $success = ( false !== $result );
978 if ( $success ) {
979 /**
980 * Fires when a site is suspended.
981 *
982 * @param object $website Site object.
983 * @param int $status Suspension status (1 = suspended).
984 */
985 do_action( 'mainwp_site_suspended', $website, 1 );
986 } else {
987 $error = __( 'Database update failed during suspend.', 'mainwp' );
988 $this->log_debug(
989 sprintf( 'Suspend failed for site %d: database update returned false', $website->id )
990 );
991 }
992 break;
993
994 default:
995 $error = __( 'Unknown operation type.', 'mainwp' );
996 }
997
998 if ( $success ) {
999 $job['successful'][] = $site_id;
1000 } else {
1001 $job['errors'][] = array(
1002 'site_id' => $site_id,
1003 'code' => $job['job_type'] . '_failed',
1004 'message' => $error,
1005 );
1006 }
1007 } catch ( \Exception $e ) {
1008 $job['errors'][] = array(
1009 'site_id' => $site_id,
1010 'code' => 'exception',
1011 'message' => $e->getMessage(),
1012 );
1013 }
1014
1015 ++$job['processed'];
1016 $job['progress'] = $job['total'] > 0
1017 ? round( ( $job['processed'] / $job['total'] ) * 100 )
1018 : 0;
1019 }
1020
1021 // Save progress.
1022 set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS );
1023
1024 // Recalculate remaining after processing.
1025 $processed_ids = array_merge(
1026 $job['successful'],
1027 array_column( $job['errors'], 'site_id' )
1028 );
1029 $remaining = array_values( array_diff( $job['sites'], $processed_ids ) );
1030
1031 // Reschedule or complete.
1032 if ( count( $remaining ) > 0 ) {
1033 // Schedule next chunk (30 second delay).
1034 if ( ! wp_next_scheduled( 'mainwp_process_batch_job', array( $job_id ) ) ) {
1035 wp_schedule_single_event( time() + 30, 'mainwp_process_batch_job', array( $job_id ) );
1036 }
1037 $this->log_debug(
1038 sprintf( 'Batch job %s rescheduled: %d sites remaining', $job_id, count( $remaining ) )
1039 );
1040 } else {
1041 // Job complete.
1042 $job['status'] = 'completed';
1043 $job['completed'] = time();
1044 $job['progress'] = 100;
1045 set_transient( 'mainwp_batch_job_' . $job_id, $job, DAY_IN_SECONDS );
1046 $this->log_debug(
1047 sprintf(
1048 'Batch job %s (%s) completed: %d successful, %d errors',
1049 $job_id,
1050 $job['job_type'],
1051 count( $job['successful'] ),
1052 count( $job['errors'] )
1053 )
1054 );
1055 }
1056 }
1057 }
1058