PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.1
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.6.1, at includes/Admin/BackgroundProcess/WP_Background_Process.php

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