PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.2.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.2.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Admin / BackgroundProcess / WP_Background_Process.php

WP_Background_Process.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.2.0, at includes/Admin/BackgroundProcess/WP_Background_Process.php

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