<?php
/**
* WooCommerce Plugin Framework
*
* This source file is subject to the GNU General Public License v3.0
* that is bundled with this package in the file license.txt.
* It is also available through the world-wide-web at this URL:
* http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@skyverge.com so we can send you a copy immediately.
*
* @since 3.0.0
* @author WooCommerce / SkyVerge / Delicious Brains
* @copyright Copyright (c) 2015-2016 Delicious Brains Inc.
* @copyright Copyright (c) 2013-2019, SkyVerge, Inc.
* @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
*
* Modified by WooCommerce on 01 December 2021.
*/
namespace WooCommerce\Square\Framework\Utilities;
use WooCommerce\Square\Framework\Plugin_Compatibility;
defined( 'ABSPATH' ) or exit;
/**
* Square WordPress Background Job Handler class
*
* Based on the wonderful WP_Background_Process class by deliciousbrains:
* https://github.com/A5hleyRich/wp-background-processing
*
* Subclasses SV_WP_Async_Request. Instead of the concept of `batches` used in
* the Delicious Brains' version, however, this takes a more object-oriented approach
* of background `jobs`, allowing greater control over manipulating job data and
* processing.
*
* A batch implicitly expected an array of items to process, whereas a job does
* not expect any particular data structure (although it does default to
* looping over job data) and allows subclasses to provide their own
* processing logic.
*
* @since 3.0.0
*/
abstract class Background_Job_Handler {
/** @var string async request prefix */
protected $prefix = 'sv_wp';
/** @var string async request action */
protected $action = 'background_job';
/** @var string data key */
protected $data_key = 'data';
/** @var int start time of current process */
protected $start_time = 0;
/** @var string cron hook identifier */
protected $cron_hook_identifier;
/** @var string cron interval identifier */
protected $cron_interval_identifier;
/** @var string debug message, used by the system status tool */
protected $debug_message;
/** @var string job identifier */
protected $identifier;
/**
* Initiate new background job handler
*
* @since 3.0.0
*/
public function __construct() {
$this->identifier = $this->prefix . '_' . $this->action;
$this->cron_hook_identifier = $this->identifier . '_cron';
$this->cron_interval_identifier = $this->identifier . '_cron_interval';
$this->add_hooks();
}
/**
* Adds the necessary action and filter hooks.
*
* @since 3.0.0
*/
protected function add_hooks() {
// cron healthcheck
add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) );
// debugging & testing
add_filter( 'gettext', array( $this, 'translate_success_message' ), 10, 3 );
}
/**
* Check whether job queue is empty or not
*
* @since 3.0.0
* @return bool True if queue is empty, false otherwise
*/
protected function is_queue_empty() {
global $wpdb;
$key = $this->identifier . '_job_%';
// only queued or processing jobs count
$queued = '%"status":"queued"%';
$processing = '%"status":"processing"%';
$count = $wpdb->get_var( $wpdb->prepare( "
SELECT COUNT(*)
FROM {$wpdb->options}
WHERE option_name LIKE %s
AND ( option_value LIKE %s OR option_value LIKE %s )
", $key, $queued, $processing ) );
return ( $count > 0 ) ? false : true;
}
/**
* Check whether background process is running or not
*
* Check whether the current process is already running
* in a background process.
*
* @since 3.0.0
* @return bool True if processing is running, false otherwise
*/
protected function is_process_running() {
// add a random artificial delay to prevent a race condition if 2 or more processes are trying to
// process the job queue at the very same moment in time and neither of them have yet set the lock
// before the others are calling this method
usleep( wp_rand( 100000, 300000 ) );
return (bool) get_transient( "{$this->identifier}_process_lock" );
}
/**
* Lock process
*
* Lock the process so that multiple instances can't run simultaneously.
* Override if applicable, but the duration should be greater than that
* defined in the time_exceeded() method.
*
* @since 3.0.0
*/
protected function lock_process() {
// set start time of current process
$this->start_time = time();
// set lock duration to 1 minute by default
$lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60;
/**
* Filter the queue lock time
*
* @since 3.0.0
* @param int $lock_duration Lock duration in seconds
*/
$lock_duration = apply_filters( "{$this->identifier}_queue_lock_time", $lock_duration );
set_transient( "{$this->identifier}_process_lock", microtime(), $lock_duration );
}
/**
* Unlock process
*
* Unlock the process so that other instances can spawn.
*
* @since 3.0.0
* @return Background_Job_Handler
*/
protected function unlock_process() {
delete_transient( "{$this->identifier}_process_lock" );
return $this;
}
/**
* Check if memory limit is exceeded
*
* Ensures the background job handler process never exceeds 90%
* of the maximum WordPress memory.
*
* @since 3.0.0
*
* @return bool True if exceeded memory limit, false otherwise
*/
protected function memory_exceeded() {
$memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory
$current_memory = memory_get_usage( true );
$return = false;
if ( $current_memory >= $memory_limit ) {
$return = true;
}
/**
* Filter whether memory limit has been exceeded or not
*
* @since 3.0.0
*
* @param bool $exceeded
*/
return apply_filters( "{$this->identifier}_memory_exceeded", $return );
}
/**
* Get memory limit
*
* @since 3.0.0
*
* @return int memory limit in bytes
*/
protected function get_memory_limit() {
if ( function_exists( 'ini_get' ) ) {
$memory_limit = ini_get( 'memory_limit' );
} else {
// sensible default
$memory_limit = '128M';
}
if ( ! $memory_limit || -1 === (int) $memory_limit ) {
// unlimited, set to 32GB
$memory_limit = '32G';
}
return Plugin_Compatibility::convert_hr_to_bytes( $memory_limit );
}
/**
* Create a background job
*
* Delicious Brains' versions alternative would be using ->data()->save().
* Allows passing in any kind of job attributes, which will be available at item data processing time.
* This allows sharing common options between items without the need to repeat
* the same information for every single item in queue.
*
* Instead of returning self, returns the job instance, which gives greater
* control over the job.
*
* @since 3.0.0
*
* @param array|mixed $attrs Job attributes.
* @return \stdClass|object|null
*/
public function create_job( $attrs ) {
global $wpdb;
if ( empty( $attrs ) ) {
return null;
}
// generate a unique ID for the job
$job_id = md5( microtime() . wp_rand() );
/**
* Filter new background job attributes
*
* @since 3.0.0
*
* @param array $attrs Job attributes
* @param string $id Job ID
*/
$attrs = apply_filters( "{$this->identifier}_new_job_attrs", $attrs, $job_id );
// ensure a few must-have attributes
$attrs = wp_parse_args( array(
'id' => $job_id,
'created_at' => current_time( 'mysql' ),
'created_by' => get_current_user_id(),
'status' => 'queued',
), $attrs );
$wpdb->insert( $wpdb->options, array(
'option_name' => "{$this->identifier}_job_{$job_id}",
'option_value' => wp_json_encode( $attrs ),
'autoload' => 'no'
) );
$job = new \stdClass();
foreach ( $attrs as $key => $value ) {
$job->{$key} = $value;
}
/**
* Runs when a job is created.
*
* @since 3.0.0
*
* @param \stdClass|object $job the created job
*/
do_action( "{$this->identifier}_job_created", $job );
return $job;
}
/**
* Get a job (by default the first in the queue)
*
* @since 3.0.0
*
* @param string $id Optional. Job ID. Will return first job in queue if not
* provided. Will not return completed or failed jobs from queue.
* @return \stdClass|object|null The found job object or null
*/
public function get_job( $id = null ) {
global $wpdb;
if ( ! $id ) {
$key = $this->identifier . '_job_%';
$queued = '%"status":"queued"%';
$processing = '%"status":"processing"%';
$results = $wpdb->get_var( $wpdb->prepare( "
SELECT option_value
FROM {$wpdb->options}
WHERE option_name LIKE %s
AND ( option_value LIKE %s OR option_value LIKE %s )
ORDER BY option_id ASC
LIMIT 1
", $key, $queued, $processing ) );
} else {
$results = $wpdb->get_var( $wpdb->prepare( "
SELECT option_value
FROM {$wpdb->options}
WHERE option_name = %s
", "{$this->identifier}_job_{$id}" ) );
}
if ( ! empty( $results ) ) {
$job = new \stdClass();
foreach ( json_decode( $results, true ) as $key => $value ) {
$job->{$key} = $value;
}
} else {
return null;
}
/**
* Filters the job as returned from the database.
*
* @since 3.0.0
*
* @param \stdClass|object $job
*/
return apply_filters( "{$this->identifier}_returned_job", $job );
}
/**
* Gets jobs.
*
* @since 3.0.0
*
* @param array $args {
* Optional. An array of arguments
*
* @type string|array $status Job status(es) to include
* @type string $order ASC or DESC. Defaults to DESC
* @type string $orderby Field to order by. Defaults to option_id
* }
* @return \stdClass[]|object[]|null Found jobs or null if none found
*/
public function get_jobs( $args = array() ) {
global $wpdb;
$args = wp_parse_args( $args, array(
'order' => 'DESC',
'orderby' => 'option_id',
) );
$replacements = array( $this->identifier . '_job_%' );
$status_query = '';
// prepare status query
if ( ! empty( $args['status'] ) ) {
$statuses = (array) $args['status'];
$placeholders = array();
foreach ( $statuses as $status ) {
$placeholders[] = '%s';
$replacements[] = '%"status":"' . sanitize_key( $status ) . '"%';
}
$status_query = 'AND ( option_value LIKE ' . implode( ' OR option_value LIKE ', $placeholders ) . ' )';
}
// prepare sorting vars
$order = sanitize_key( $args['order'] );
$orderby = sanitize_key( $args['orderby'] );
// put it all together now
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Data is already sanitized to direct use.
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_value
FROM {$wpdb->options}
WHERE option_name LIKE %s
{$status_query}
ORDER BY {$orderby} {$order}",
$replacements
)
);
// phpcs:enable
if ( empty( $results ) ) {
return null;
}
$jobs = array();
foreach ( $results as $result ) {
$job = new \stdClass();
foreach ( json_decode( $result, true ) as $key => $value ) {
$job->{$key} = $value;
}
/* This filter is documented above. */
$job = apply_filters( "{$this->identifier}_returned_job", $job );
$jobs[] = $job;
}
return $jobs;
}
/**
* Update job attrs
*
* @since 3.0.0
*
* @param \stdClass|object|string $job Job instance or ID
* @return \stdClass|object|false on failure
*/
public function update_job( $job ) {
if ( is_string( $job ) ) {
$job = $this->get_job( $job );
}
if ( ! $job ) {
return false;
}
$job->updated_at = current_time( 'mysql' );
$this->update_job_option( $job );
/**
* Runs when a job is updated.
*
* @since 3.0.0
*
* @param \stdClass|object $job the updated job
*/
do_action( "{$this->identifier}_job_updated", $job );
return $job;
}
/**
* Handles job completion.
*
* @since 3.0.0
*
* @param \stdClass|object|string $job Job instance or ID
* @return \stdClass|object|false on failure
*/
public function complete_job( $job ) {
if ( is_string( $job ) ) {
$job = $this->get_job( $job );
}
if ( ! $job ) {
return false;
}
$job->status = 'completed';
$job->completed_at = current_time( 'mysql' );
$this->update_job_option( $job );
/**
* Runs when a job is completed.
*
* @since 3.0.0
*
* @param \stdClass|object $job the completed job
*/
do_action( "{$this->identifier}_job_complete", $job );
return $job;
}
/**
* Handle job failure
*
* Default implementation does not call this method directly, but it's
* provided as a convenience method for subclasses that may call this to
* indicate that a particular job has failed for some reason.
*
* @since 3.0.0
*
* @param \stdClass|object|string $job Job instance or ID
* @param string $reason Optional. Reason for failure.
* @return \stdClass|false on failure
*/
public function fail_job( $job, $reason = '' ) {
if ( is_string( $job ) ) {
$job = $this->get_job( $job );
}
if ( ! $job ) {
return false;
}
$job->status = 'failed';
$job->failed_at = current_time( 'mysql' );
if ( $reason ) {
$job->failure_reason = $reason;
}
$this->update_job_option( $job );
/**
* Runs when a job is failed.
*
* @since 3.0.0
*
* @param \stdClass|object $job the failed job
*/
do_action( "{$this->identifier}_job_failed", $job );
return $job;
}
/**
* Delete a job
*
* @since 3.0.0
*
* @param \stdClass|object|string $job Job instance or ID
* @return bool|void returns false on failure
*/
public function delete_job( $job ) {
global $wpdb;
if ( is_string( $job ) ) {
$job = $this->get_job( $job );
}
if ( ! $job ) {
return false;
}
$wpdb->delete( $wpdb->options, array( 'option_name' => "{$this->identifier}_job_{$job->id}" ) );
/**
* Runs after a job is deleted.
*
* @since 3.0.0
*
* @param \stdClass|object $job the job that was deleted from database
*/
do_action( "{$this->identifier}_job_deleted", $job );
}
/**
* Handle job queue completion
*
* Override if applicable, but ensure that the below actions are
* performed, or, call parent::complete().
*
* @since 3.0.0
*/
protected function complete() {
// unschedule the cron healthcheck
$this->clear_scheduled_event();
}
/**
* Schedule cron healthcheck
*
* @since 3.0.0
* @param array $schedules
* @return array
*/
public function schedule_cron_healthcheck( $schedules ) {
$interval = property_exists( $this, 'cron_interval' ) ? $this->cron_interval : 5;
/**
* Filter cron health check interval
*
* @since 3.0.0
* @param int $interval Interval in minutes
*/
$interval = apply_filters( "{$this->identifier}_cron_interval", $interval );
// adds every 5 minutes to the existing schedules.
$schedules[ $this->identifier . '_cron_interval' ] = array(
'interval' => MINUTE_IN_SECONDS * $interval,
/* translators: %d: interval in minutes */
'display' => sprintf( esc_html__( 'Every %d Minutes', 'woocommerce-square' ), $interval ),
);
return $schedules;
}
/**
* Schedule cron health check event
*
* @since 3.0.0
*/
protected function schedule_event() {
/**
* Filters the interval of sync healthcheck action.
*
* @since 3.8.2
*
* @param int $interval sync heathcheck interval in seconds (defaults to 5 minutes)
*/
$interval = apply_filters( 'wc_square_sync_healthcheck_interval', 300 );
$group = 'square';
if ( false === as_next_scheduled_action( $this->cron_hook_identifier, array(), $group ) ) {
// Schedule the health check to fire after 5 mins from now, and run every 5 mins until queue get empty.
as_schedule_recurring_action( time() + $interval, $interval, $this->cron_hook_identifier, array(), $group );
}
}
/**
* Clear scheduled health check event
*
* @since 3.0.0
*/
protected function clear_scheduled_event() {
as_unschedule_all_actions( $this->cron_hook_identifier );
}
/**
* Process an item from job data
*
* Implement this method to perform any actions required on each
* item in job data.
*
* @since 3.0.0
*
* @param mixed $item Job data item to iterate over
* @param \stdClass|object $job Job instance
* @return mixed
*/
abstract protected function process_item( $item, $job );
/**
* Handles PHP shutdown, say after a fatal error.
*
* @since 3.0.0
*
* @param \stdClass|object $job the job being processed
*/
public function handle_shutdown( $job ) {
$error = error_get_last();
// if shutting down because of a fatal error, fail the job
if ( $error && E_ERROR === $error['type'] ) {
$this->fail_job( $job, $error['message'] );
$this->unlock_process();
}
}
/**
* Update a job option in options database.
*
* @since 3.0.0
*
* @param \stdClass|object $job the job instance to update in database
* @return int|bool number of rows updated or false on failure, see wpdb::update()
*/
private function update_job_option( $job ) {
global $wpdb;
return $wpdb->update(
$wpdb->options,
array( 'option_value' => wp_json_encode( $job ) ),
array( 'option_name' => "{$this->identifier}_job_{$job->id}" )
);
}
/** Debug & Testing Methods ***********************************************/
/**
* Translate the tool success message.
*
* This can be removed in favor of returning the message string in `run_debug_tool()`
* when WC 3.1 is required, though that means the message will always be "success" styled.
*
* @since 3.0.0
*
* @param string $translated the text to output
* @param string $original the original text
* @param string $domain the textdomain
* @return string the updated text
*/
public function translate_success_message( $translated, $original, $domain ) {
if ( 'woocommerce' === $domain && ( 'Tool ran.' === $original || 'There was an error calling %s' === $original ) ) {
$translated = $this->debug_message;
}
return $translated;
}
/** Helper Methods ********************************************************/
/**
* Gets the job handler identifier.
*
* @since 3.0.0
*
* @return string
*/
public function get_identifier() {
return $this->identifier;
}
}