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