PluginProbe
Solace Extra / trunk
Solace Extra vtrunk
1.7.1 1.7.0 1.6.2 1.6.1 1.6.0 1.5.3 trunk 1.0.8 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.7 1.1.8 1.1.9 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.5.0 1.5.1 All 26 releases
solace-extra / admin / classes / wp-background-process.php

wp-background-process.php in Solace Extra trunk, at admin/classes/wp-background-process.php

799 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5 /**
6 * WP Background Process
7 *
8 * @package WP-Background-Processing
9 */
10
11 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- WordPress importer uses core/legacy hook names for compatibility.
12
13 /**
14 * Abstract WP_Background_Process class.
15 *
16 * @abstract
17 * @extends WP_Async_Request
18 */
19 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- Vendored WP-Background-Processing library class.
20 abstract class XWP_Background_Process extends XWP_Async_Request {
21
22 /**
23 * Action
24 *
25 * (default value: 'background_process')
26 *
27 * @var string
28 * @access protected
29 */
30 protected $action = 'background_process';
31
32 /**
33 * Start time of current process.
34 *
35 * (default value: 0)
36 *
37 * @var int
38 * @access protected
39 */
40 protected $start_time = 0;
41
42 /**
43 * Cron_hook_identifier
44 *
45 * @var string
46 * @access protected
47 */
48 protected $cron_hook_identifier;
49
50 /**
51 * Cron_interval_identifier
52 *
53 * @var string
54 * @access protected
55 */
56 protected $cron_interval_identifier;
57
58 /**
59 * Restrict object instantiation when using unserialize.
60 *
61 * @var bool|array
62 */
63 protected $allowed_batch_data_classes = true;
64
65 /**
66 * The status set when process is cancelling.
67 *
68 * @var int
69 */
70 const STATUS_CANCELLED = 1;
71
72 /**
73 * The status set when process is paused or pausing.
74 *
75 * @var int;
76 */
77 const STATUS_PAUSED = 2;
78
79 /**
80 * Initiate new background process.
81 *
82 * @param bool|array $allowed_batch_data_classes Optional. Array of class names that can be unserialized. Default true (any class).
83 */
84 public function __construct( $allowed_batch_data_classes = true ) {
85 parent::__construct();
86
87 if ( empty( $allowed_batch_data_classes ) && false !== $allowed_batch_data_classes ) {
88 $allowed_batch_data_classes = true;
89 }
90
91 if ( ! is_bool( $allowed_batch_data_classes ) && ! is_array( $allowed_batch_data_classes ) ) {
92 $allowed_batch_data_classes = true;
93 }
94
95 // If allowed_batch_data_classes property set in subclass,
96 // only apply override if not allowing any class.
97 if ( true === $this->allowed_batch_data_classes || true !== $allowed_batch_data_classes ) {
98 $this->allowed_batch_data_classes = $allowed_batch_data_classes;
99 }
100
101 $this->cron_hook_identifier = $this->identifier . '_cron';
102 $this->cron_interval_identifier = $this->identifier . '_cron_interval';
103
104 add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) );
105 add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) );
106 }
107
108 /**
109 * Schedule the cron healthcheck and dispatch an async request to start processing the queue.
110 *
111 * @access public
112 * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted.
113 */
114 public function dispatch() {
115 if ( $this->is_processing() ) {
116 // Process already running.
117 return false;
118 }
119
120 // Schedule the cron healthcheck.
121 $this->schedule_event();
122
123 // Perform remote post.
124 return parent::dispatch();
125 }
126
127 /**
128 * Push to the queue.
129 *
130 * Note, save must be called in order to persist queued items to a batch for processing.
131 *
132 * @param mixed $data Data.
133 *
134 * @return $this
135 */
136 public function push_to_queue( $data ) {
137 $this->data[] = $data;
138
139 return $this;
140 }
141
142 /**
143 * Save the queued items for future processing.
144 *
145 * @return $this
146 */
147 public function save() {
148 $key = $this->generate_key();
149
150 if ( ! empty( $this->data ) ) {
151 update_site_option( $key, $this->data );
152 }
153
154 // Clean out data so that new data isn't prepended with closed session's data.
155 $this->data = array();
156
157 return $this;
158 }
159
160 /**
161 * Update a batch's queued items.
162 *
163 * @param string $key Key.
164 * @param array $data Data.
165 *
166 * @return $this
167 */
168 public function update( $key, $data ) {
169 if ( ! empty( $data ) ) {
170 update_site_option( $key, $data );
171 }
172
173 return $this;
174 }
175
176 /**
177 * Delete a batch of queued items.
178 *
179 * @param string $key Key.
180 *
181 * @return $this
182 */
183 public function delete( $key ) {
184 delete_site_option( $key );
185
186 return $this;
187 }
188
189 /**
190 * Delete entire job queue.
191 */
192 public function delete_all() {
193 $batches = $this->get_batches();
194
195 foreach ( $batches as $batch ) {
196 $this->delete( $batch->key );
197 }
198
199 delete_site_option( $this->get_status_key() );
200
201 $this->cancelled();
202 }
203
204 /**
205 * Cancel job on next batch.
206 */
207 public function cancel() {
208 update_site_option( $this->get_status_key(), self::STATUS_CANCELLED );
209
210 // Just in case the job was paused at the time.
211 $this->dispatch();
212 }
213
214 /**
215 * Has the process been cancelled?
216 *
217 * @return bool
218 */
219 public function is_cancelled() {
220 $status = get_site_option( $this->get_status_key(), 0 );
221
222 return absint( $status ) === self::STATUS_CANCELLED;
223 }
224
225 /**
226 * Called when background process has been cancelled.
227 */
228 protected function cancelled() {
229 do_action( $this->identifier . '_cancelled' );
230 }
231
232 /**
233 * Pause job on next batch.
234 */
235 public function pause() {
236 update_site_option( $this->get_status_key(), self::STATUS_PAUSED );
237 }
238
239 /**
240 * Is the job paused?
241 *
242 * @return bool
243 */
244 public function is_paused() {
245 $status = get_site_option( $this->get_status_key(), 0 );
246
247 return absint( $status ) === self::STATUS_PAUSED;
248 }
249
250 /**
251 * Called when background process has been paused.
252 */
253 protected function paused() {
254 do_action( $this->identifier . '_paused' );
255 }
256
257 /**
258 * Resume job.
259 */
260 public function resume() {
261 delete_site_option( $this->get_status_key() );
262
263 $this->schedule_event();
264 $this->dispatch();
265 $this->resumed();
266 }
267
268 /**
269 * Called when background process has been resumed.
270 */
271 protected function resumed() {
272 do_action( $this->identifier . '_resumed' );
273 }
274
275 /**
276 * Is queued?
277 *
278 * @return bool
279 */
280 public function is_queued() {
281 return ! $this->is_queue_empty();
282 }
283
284 /**
285 * Is the tool currently active, e.g. starting, working, paused or cleaning up?
286 *
287 * @return bool
288 */
289 public function is_active() {
290 return $this->is_queued() || $this->is_processing() || $this->is_paused() || $this->is_cancelled();
291 }
292
293 /**
294 * Generate key for a batch.
295 *
296 * Generates a unique key based on microtime. Queue items are
297 * given a unique key so that they can be merged upon save.
298 *
299 * @param int $length Optional max length to trim key to, defaults to 64 characters.
300 * @param string $key Optional string to append to identifier before hash, defaults to "batch".
301 *
302 * @return string
303 */
304 protected function generate_key( $length = 64, $key = 'batch' ) {
305 $unique = md5( microtime() . wp_rand() );
306 $prepend = $this->identifier . '_' . $key . '_';
307
308 return substr( $prepend . $unique, 0, $length );
309 }
310
311 /**
312 * Get the status key.
313 *
314 * @return string
315 */
316 protected function get_status_key() {
317 return $this->identifier . '_status';
318 }
319
320 /**
321 * Maybe process a batch of queued items.
322 *
323 * Checks whether data exists within the queue and that
324 * the process is not already running.
325 */
326 public function maybe_handle() {
327 // Don't lock up other requests while processing.
328 session_write_close();
329
330 if ( $this->is_processing() ) {
331 // Background process already running.
332 return $this->maybe_wp_die();
333 }
334
335 if ( $this->is_cancelled() ) {
336 $this->clear_scheduled_event();
337 $this->delete_all();
338
339 return $this->maybe_wp_die();
340 }
341
342 if ( $this->is_paused() ) {
343 $this->clear_scheduled_event();
344 $this->paused();
345
346 return $this->maybe_wp_die();
347 }
348
349 if ( $this->is_queue_empty() ) {
350 // No data to process.
351 return $this->maybe_wp_die();
352 }
353
354 check_ajax_referer( $this->identifier, 'nonce' );
355
356 $this->handle();
357
358 return $this->maybe_wp_die();
359 }
360
361 /**
362 * Is queue empty?
363 *
364 * @return bool
365 */
366 protected function is_queue_empty() {
367 return empty( $this->get_batch() );
368 }
369
370 /**
371 * Is process running?
372 *
373 * Check whether the current process is already running
374 * in a background process.
375 *
376 * @return bool
377 *
378 * @deprecated 1.1.0 Superseded.
379 * @see is_processing()
380 */
381 protected function is_process_running() {
382 return $this->is_processing();
383 }
384
385 /**
386 * Is the background process currently running?
387 *
388 * @return bool
389 */
390 public function is_processing() {
391 if ( get_site_transient( $this->identifier . '_process_lock' ) ) {
392 // Process already running.
393 return true;
394 }
395
396 return false;
397 }
398
399 /**
400 * Lock process.
401 *
402 * Lock the process so that multiple instances can't run simultaneously.
403 * Override if applicable, but the duration should be greater than that
404 * defined in the time_exceeded() method.
405 */
406 protected function lock_process() {
407 $this->start_time = time(); // Set start time of current process.
408
409 $lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute
410 $lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );
411
412 set_site_transient( $this->identifier . '_process_lock', microtime(), $lock_duration );
413 }
414
415 /**
416 * Unlock process.
417 *
418 * Unlock the process so that other instances can spawn.
419 *
420 * @return $this
421 */
422 protected function unlock_process() {
423 delete_site_transient( $this->identifier . '_process_lock' );
424
425 return $this;
426 }
427
428 /**
429 * Get batch.
430 *
431 * @return stdClass Return the first batch of queued items.
432 */
433 protected function get_batch() {
434 return array_reduce(
435 $this->get_batches( 1 ),
436 static function ( $carry, $batch ) {
437 return $batch;
438 },
439 array()
440 );
441 }
442
443 /**
444 * Get batches.
445 *
446 * @param int $limit Number of batches to return, defaults to all.
447 *
448 * @return array of stdClass
449 */
450 public function get_batches( $limit = 0 ) {
451 global $wpdb;
452
453 if ( empty( $limit ) || ! is_int( $limit ) ) {
454 $limit = 0;
455 }
456
457 $table = $wpdb->options;
458 $column = 'option_name';
459 $key_column = 'option_id';
460 $value_column = 'option_value';
461
462 if ( is_multisite() ) {
463 $table = $wpdb->sitemeta;
464 $column = 'meta_key';
465 $key_column = 'meta_id';
466 $value_column = 'meta_value';
467 }
468
469 $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
470
471 // Table and column names are WordPress core identifiers ($wpdb->options, $wpdb->sitemeta, 'option_name', 'meta_key', etc.), safe to use directly.
472 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- WordPress core table/column identifiers are safe
473 $sql = '
474 SELECT *
475 FROM ' . $table . '
476 WHERE ' . $column . ' LIKE %s
477 ORDER BY ' . $key_column . ' ASC
478 ';
479
480 $args = array( $key );
481
482 if ( ! empty( $limit ) ) {
483 $sql .= ' LIMIT %d';
484
485 $args[] = $limit;
486 }
487
488 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table/column names are WordPress core identifiers, $key is escaped via esc_like()
489 $items = $wpdb->get_results( $wpdb->prepare( $sql, $args ) );
490 // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter
491
492 $batches = array();
493
494 if ( ! empty( $items ) ) {
495 $allowed_classes = $this->allowed_batch_data_classes;
496
497 $batches = array_map(
498 static function ( $item ) use ( $column, $value_column, $allowed_classes ) {
499 $batch = new stdClass();
500 $batch->key = $item->{$column};
501 $batch->data = static::maybe_unserialize( $item->{$value_column}, $allowed_classes );
502
503 return $batch;
504 },
505 $items
506 );
507 }
508
509 return $batches;
510 }
511
512 /**
513 * Handle a dispatched request.
514 *
515 * Pass each queue item to the task handler, while remaining
516 * within server memory and time limit constraints.
517 */
518 protected function handle() {
519 $this->lock_process();
520
521 /**
522 * Number of seconds to sleep between batches. Defaults to 0 seconds, minimum 0.
523 *
524 * @param int $seconds
525 */
526 $throttle_seconds = max(
527 0,
528 apply_filters(
529 $this->identifier . '_seconds_between_batches',
530 apply_filters(
531 $this->prefix . '_seconds_between_batches',
532 0
533 )
534 )
535 );
536
537 do {
538 $batch = $this->get_batch();
539
540 foreach ( $batch->data as $key => $value ) {
541 $task = $this->task( $value );
542
543 if ( false !== $task ) {
544 $batch->data[ $key ] = $task;
545 } else {
546 unset( $batch->data[ $key ] );
547 }
548
549 // Keep the batch up to date while processing it.
550 if ( ! empty( $batch->data ) ) {
551 $this->update( $batch->key, $batch->data );
552 }
553
554 // Let the server breathe a little.
555 sleep( $throttle_seconds );
556
557 // Batch limits reached, or pause or cancel request.
558 if ( $this->time_exceeded() || $this->memory_exceeded() || $this->is_paused() || $this->is_cancelled() ) {
559 break;
560 }
561 }
562
563 // Delete current batch if fully processed.
564 if ( empty( $batch->data ) ) {
565 $this->delete( $batch->key );
566 }
567 } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() && ! $this->is_paused() && ! $this->is_cancelled() );
568
569 $this->unlock_process();
570
571 // Start next batch or complete process.
572 if ( ! $this->is_queue_empty() ) {
573 $this->dispatch();
574 } else {
575 $this->complete();
576 }
577
578 return $this->maybe_wp_die();
579 }
580
581 /**
582 * Memory exceeded?
583 *
584 * Ensures the batch process never exceeds 90%
585 * of the maximum WordPress memory.
586 *
587 * @return bool
588 */
589 protected function memory_exceeded() {
590 $memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory
591 $current_memory = memory_get_usage( true );
592 $return = false;
593
594 if ( $current_memory >= $memory_limit ) {
595 $return = true;
596 }
597
598 return apply_filters( $this->identifier . '_memory_exceeded', $return );
599 }
600
601 /**
602 * Get memory limit in bytes.
603 *
604 * @return int
605 */
606 protected function get_memory_limit() {
607 if ( function_exists( 'ini_get' ) ) {
608 $memory_limit = ini_get( 'memory_limit' );
609 } else {
610 // Sensible default.
611 $memory_limit = '128M';
612 }
613
614 if ( ! $memory_limit || -1 === intval( $memory_limit ) ) {
615 // Unlimited, set to 32GB.
616 $memory_limit = '32000M';
617 }
618
619 return wp_convert_hr_to_bytes( $memory_limit );
620 }
621
622 /**
623 * Time limit exceeded?
624 *
625 * Ensures the batch never exceeds a sensible time limit.
626 * A timeout limit of 30s is common on shared hosting.
627 *
628 * @return bool
629 */
630 protected function time_exceeded() {
631 $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds
632 $return = false;
633
634 if ( time() >= $finish ) {
635 $return = true;
636 }
637
638 return apply_filters( $this->identifier . '_time_exceeded', $return );
639 }
640
641 /**
642 * Complete processing.
643 *
644 * Override if applicable, but ensure that the below actions are
645 * performed, or, call parent::complete().
646 */
647 protected function complete() {
648 delete_site_option( $this->get_status_key() );
649
650 // Remove the cron healthcheck job from the cron schedule.
651 $this->clear_scheduled_event();
652
653 $this->completed();
654 }
655
656 /**
657 * Called when background process has completed.
658 */
659 protected function completed() {
660 do_action( $this->identifier . '_completed' );
661 }
662
663 /**
664 * Get the cron healthcheck interval in minutes.
665 *
666 * Default is 5 minutes, minimum is 1 minute.
667 *
668 * @return int
669 */
670 public function get_cron_interval() {
671 $interval = 5;
672
673 if ( property_exists( $this, 'cron_interval' ) ) {
674 $interval = $this->cron_interval;
675 }
676
677 $interval = apply_filters( $this->cron_interval_identifier, $interval );
678
679 return is_int( $interval ) && 0 < $interval ? $interval : 5;
680 }
681
682 /**
683 * Schedule the cron healthcheck job.
684 *
685 * @access public
686 *
687 * @param mixed $schedules Schedules.
688 *
689 * @return mixed
690 */
691 public function schedule_cron_healthcheck( $schedules ) {
692 $interval = $this->get_cron_interval();
693
694 if ( 1 === $interval ) {
695 // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
696 $display = __( 'Every Minute' );
697 } else {
698 // phpcs:ignore WordPress.WP.I18n.MissingArgDomain,WordPress.WP.I18n.MissingTranslatorsComment
699 $display = sprintf( __( 'Every %d Minutes' ), $interval );
700 }
701
702 // Adds an "Every NNN Minute(s)" schedule to the existing cron schedules.
703 $schedules[ $this->cron_interval_identifier ] = array(
704 'interval' => MINUTE_IN_SECONDS * $interval,
705 'display' => $display,
706 );
707
708 return $schedules;
709 }
710
711 /**
712 * Handle cron healthcheck event.
713 *
714 * Restart the background process if not already running
715 * and data exists in the queue.
716 */
717 public function handle_cron_healthcheck() {
718 if ( $this->is_processing() ) {
719 // Background process already running.
720 exit;
721 }
722
723 if ( $this->is_queue_empty() ) {
724 // No data to process.
725 $this->clear_scheduled_event();
726 exit;
727 }
728
729 $this->dispatch();
730 }
731
732 /**
733 * Schedule the cron healthcheck event.
734 */
735 protected function schedule_event() {
736 if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
737 wp_schedule_event( time() + ( $this->get_cron_interval() * MINUTE_IN_SECONDS ), $this->cron_interval_identifier, $this->cron_hook_identifier );
738 }
739 }
740
741 /**
742 * Clear scheduled cron healthcheck event.
743 */
744 protected function clear_scheduled_event() {
745 $timestamp = wp_next_scheduled( $this->cron_hook_identifier );
746
747 if ( $timestamp ) {
748 wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
749 }
750 }
751
752 /**
753 * Cancel the background process.
754 *
755 * Stop processing queue items, clear cron job and delete batch.
756 *
757 * @deprecated 1.1.0 Superseded.
758 * @see cancel()
759 */
760 public function cancel_process() {
761 $this->cancel();
762 }
763
764 /**
765 * Perform task with queued item.
766 *
767 * Override this method to perform any actions required on each
768 * queue item. Return the modified item for further processing
769 * in the next pass through. Or, return false to remove the
770 * item from the queue.
771 *
772 * @param mixed $item Queue item to iterate over.
773 *
774 * @return mixed
775 */
776 abstract protected function task( $item );
777
778 /**
779 * Maybe unserialize data, but not if an object.
780 *
781 * @param mixed $data Data to be unserialized.
782 * @param bool|array $allowed_classes Array of class names that can be unserialized.
783 *
784 * @return mixed
785 */
786 protected static function maybe_unserialize( $data, $allowed_classes ) {
787 if ( is_serialized( $data ) ) {
788 $options = array();
789 if ( is_bool( $allowed_classes ) || is_array( $allowed_classes ) ) {
790 $options['allowed_classes'] = $allowed_classes;
791 }
792
793 return @unserialize( $data, $options ); // @phpcs:ignore
794 }
795
796 return $data;
797 }
798 }
799