<?php
/**
* Facebook for WooCommerce.
*/
namespace WooCommerce\Facebook\Framework\Utilities;
use WooCommerce\Facebook\Framework\Plugin\Compatibility;
use WooCommerce\Facebook\Framework\Helper;
defined( 'ABSPATH' ) || exit;
/**
* SkyVerge 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.
*
* # Sample usage:
*
* $background_job_handler = new SV_WP_Background_Job_Handler();
* $job = $background_job_handler->create_job( $attrs );
* $background_job_handler->dispatch();
*
* @since 4.4.0
*/
abstract class BackgroundJobHandler extends AsyncRequest {
/** @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;
/**
* Initiate new background job handler
*
* @since 4.4.0
*/
public function __construct() {
parent::__construct();
$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 4.8.0
*/
protected function add_hooks() {
// cron healthcheck
add_action( $this->cron_hook_identifier, [ $this, 'handle_cron_healthcheck' ] );
/* phpcs:ignore WordPress.WP.CronInterval.ChangeDetected */
add_filter( 'cron_schedules', [ $this, 'schedule_cron_healthcheck' ] );
// debugging & testing
add_action( "wp_ajax_nopriv_{$this->identifier}_test", [ $this, 'handle_connection_test_response' ] );
add_filter( 'woocommerce_debug_tools', [ $this, 'add_debug_tool' ] );
add_filter( 'gettext', [ $this, 'translate_success_message' ], 10, 3 );
}
/**
* Dispatch
*
* @since 4.4.0
* @return array|\WP_Error
*/
public function dispatch() {
// schedule the cron healthcheck
$this->schedule_event();
// perform remote post
return parent::dispatch();
}
/**
* Maybe processes job queue.
*
* Checks whether data exists within the job queue and that the background process is not already running.
*
* @since 4.4.0
*
* @throws \Exception Upon error.
*/
public function maybe_handle() {
if ( $this->is_process_running() ) {
// background process already running
wp_die();
}
if ( $this->is_queue_empty() ) {
// no data to process
wp_die();
}
/**
* WC core does 2 things here that can interfere with our nonce check:
*
* 1. WooCommerce starts a session due to our GET request to dispatch a job
* However, this happens *after* we've generated a nonce without a session (in CRON context)
* 2. it then filters nonces for logged-out users indiscriminately without checking the nonce action; if
* there is a session created (and now the server does have one), it tries to filter every.single.nonce
* for logged-out users to use the customer session ID instead of 0 for user ID. We *want* to check
* against a UID of 0 (since that's how the nonce was created), so we temporarily pause the
* logged-out nonce hijacking before standing aside.
*
* @see \WC_Session_Handler::init() when the action is hooked
* @see \WC_Session_Handler::nonce_user_logged_out() WC < 5.3 callback
* @see \WC_Session_Handler::maybe_update_nonce_user_logged_out() WC >= 5.3 callback
*/
if ( Compatibility::is_wc_version_gte( '5.3' ) ) {
$callback = [ WC()->session, 'maybe_update_nonce_user_logged_out' ];
$arguments = 2;
} else {
$callback = [ WC()->session, 'nonce_user_logged_out' ];
$arguments = 1;
}
remove_filter( 'nonce_user_logged_out', $callback );
check_ajax_referer( $this->identifier, 'nonce' );
// sorry, later nonce users! please play again
add_filter( 'nonce_user_logged_out', $callback, 10, $arguments );
$this->handle();
wp_die();
}
/**
* Check whether job queue is empty or not
*
* @since 4.4.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 intval( $count ) === 0;
}
/**
* Check whether background process is running or not
*
* Check whether the current process is already running
* in a background process.
*
* @since 4.4.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 4.4.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 4.4.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 4.4.0
* @return BackgroundJobHandler
*/
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 4.4.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 4.4.0
*
* @param bool $exceeded
*/
return apply_filters( "{$this->identifier}_memory_exceeded", $return );
}
/**
* Get memory limit
*
* @since 4.4.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 Compatibility::convert_hr_to_bytes( $memory_limit );
}
/**
* Check whether request time limit has been exceeded or not
*
* Ensures the background job handler never exceeds a sensible time limit.
* A timeout limit of 30s is common on shared hosting.
*
* @since 4.4.0
*
* @return bool True, if time limit exceeded, false otherwise
*/
protected function time_exceeded() {
/**
* Filter default time limit for background job execution, defaults to
* 20 seconds
*
* @since 4.4.0
*
* @param int $time Time in seconds
*/
$finish = $this->start_time + apply_filters( "{$this->identifier}_default_time_limit", 20 );
$return = false;
if ( time() >= $finish ) {
$return = true;
}
/**
* Filter whether maximum execution time has exceeded or not
*
* @since 4.4.0
* @param bool $exceeded true if execution time exceeded, false otherwise
*/
return apply_filters( "{$this->identifier}_time_exceeded", $return );
}
/**
* 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 4.4.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 4.4.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(
[
'id' => $job_id,
'created_at' => current_time( 'mysql' ),
'created_by' => get_current_user_id(),
'status' => 'queued',
],
$attrs
);
$wpdb->insert(
$wpdb->options,
[
'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 4.4.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 4.4.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 4.4.0
*
* @param \stdClass|object $job
*/
return apply_filters( "{$this->identifier}_returned_job", $job );
}
/**
* Gets jobs.
*
* @since 4.4.2
*
* @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 = [] ) {
global $wpdb;
$args = wp_parse_args(
$args,
[
'order' => 'DESC',
'orderby' => 'option_id',
]
);
$replacements = [ $this->identifier . '_job_%' ];
$status_query = '';
// prepare status query
if ( ! empty( $args['status'] ) ) {
$statuses = (array) $args['status'];
$placeholders = [];
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
$query = $wpdb->prepare(
/* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
"SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s {$status_query} ORDER BY {$orderby} {$order}",
$replacements
);
/* phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared */
$results = $wpdb->get_col( $query );
if ( empty( $results ) ) {
return null;
}
$jobs = [];
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;
}
/**
* Handles jobs.
*
* Process jobs while remaining within server memory and time limit constraints.
*
* @since 4.4.0
*
* @throws \Exception Upon error.
*/
protected function handle() {
$this->lock_process();
do {
// Get next job in the queue
$job = $this->get_job();
// handle PHP errors from here on out
register_shutdown_function( [ $this, 'handle_shutdown' ], $job );
// Start processing
$this->process_job( $job );
} while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() );
$this->unlock_process();
// Start next job or complete process
if ( ! $this->is_queue_empty() ) {
$this->dispatch();
} else {
$this->complete();
}
wp_die();
}
/**
* Process a job
*
* Default implementation is to loop over job data and passing each item to
* the item processor. Subclasses are, however, welcome to override this method
* to create totally different job processing implementations - see
* WC_CSV_Import_Suite_Background_Import in CSV Import for an example.
*
* If using the default implementation, the job must have a $data_key property set.
* Subclasses can override the data key, but the contents must be an array which
* the job processor can loop over. By default, the data key is `data`.
*
* If no data is set, the job will completed right away.
*
* @since 4.4.0
*
* @param \stdClass|object $job
* @param int $items_per_batch number of items to process in a single request. Defaults to unlimited.
* @throws \Exception When job data is incorrect.
* @return \stdClass $job
*/
public function process_job( $job, $items_per_batch = null ) {
if ( ! $this->start_time ) {
$this->start_time = time();
}
// Indicate that the job has started processing
if ( 'processing' !== $job->status ) {
$job->status = 'processing';
$job->started_processing_at = current_time( 'mysql' );
$job = $this->update_job( $job );
}
$data_key = $this->data_key;
if ( ! isset( $job->{$data_key} ) ) {
/* translators: Placeholders: %s - user-friendly error message */
throw new \Exception( sprintf( __( 'Job data key "%s" not set', 'facebook-for-woocommerce' ), $data_key ) );
}
if ( ! is_array( $job->{$data_key} ) ) {
/* translators: Placeholders: %s - user-friendly error message */
throw new \Exception( sprintf( __( 'Job data key "%s" is not an array', 'facebook-for-woocommerce' ), $data_key ) );
}
$data = $job->{$data_key};
$job->total = count( $data );
// progress indicates how many items have been processed, it
// does NOT indicate the processed item key in any way
if ( ! isset( $job->progress ) ) {
$job->progress = 0;
}
// skip already processed items
if ( $job->progress && ! empty( $data ) ) {
$data = array_slice( $data, $job->progress, null, true );
}
// loop over unprocessed items and process them
if ( ! empty( $data ) ) {
$processed = 0;
$items_per_batch = (int) $items_per_batch;
foreach ( $data as $item ) {
// process the item
$this->process_item( $item, $job );
++$processed;
++$job->progress;
// update job progress
$job = $this->update_job( $job );
// job limits reached
if ( ( $items_per_batch && $processed >= $items_per_batch ) || $this->time_exceeded() || $this->memory_exceeded() ) {
break;
}
}
}
// complete current job
if ( $job->progress >= count( $job->{$data_key} ) ) {
$job = $this->complete_job( $job );
}
return $job;
}
/**
* Update job attrs
*
* @since 4.4.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 4.4.0
*
* @param \stdClass|object $job the updated job
*/
do_action( "{$this->identifier}_job_updated", $job );
return $job;
}
/**
* Handles job completion.
*
* @since 4.4.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 4.4.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 4.4.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 4.4.0
*
* @param \stdClass|object $job the failed job
*/
do_action( "{$this->identifier}_job_failed", $job );
return $job;
}
/**
* Delete a job
*
* @since 4.4.2
*
* @param \stdClass|object|string $job Job instance or ID
* @return 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, [ 'option_name' => "{$this->identifier}_job_{$job->id}" ] );
/**
* Runs after a job is deleted.
*
* @since 4.4.2
*
* @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 4.4.0
*/
protected function complete() {
// unschedule the cron healthcheck
$this->clear_scheduled_event();
}
/**
* Schedule cron healthcheck
*
* @since 4.4.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 4.4.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' ] = [
'interval' => MINUTE_IN_SECONDS * $interval,
/* translators: %d - interval in minutes. */
'display' => sprintf( __( 'Every %d Minutes', 'facebook-for-woocommerce' ), $interval ),
];
return $schedules;
}
/**
* Handle cron healthcheck
*
* Restart the background process if not already running
* and data exists in the queue.
*
* @since 4.4.0
*/
public function handle_cron_healthcheck() {
if ( $this->is_process_running() ) {
// background process already running
exit;
}
if ( $this->is_queue_empty() ) {
// no data to process
$this->clear_scheduled_event();
exit;
}
$this->dispatch();
}
/**
* Schedule cron health check event
*
* @since 4.4.0
*/
protected function schedule_event() {
if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
// schedule the health check to fire after 30 seconds from now, as to not create a race condition
// with job process lock on servers that fire & handle cron events instantly
wp_schedule_event( time() + 30, $this->cron_interval_identifier, $this->cron_hook_identifier );
}
}
/**
* Clear scheduled health check event
*
* @since 4.4.0
*/
protected function clear_scheduled_event() {
$timestamp = wp_next_scheduled( $this->cron_hook_identifier );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, $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 4.4.2
*
* @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 4.5.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 4.6.3
*
* @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,
[ 'option_value' => wp_json_encode( $job ) ],
[ 'option_name' => "{$this->identifier}_job_{$job->id}" ]
);
}
/** Debug & Testing Methods ***********************************************/
/**
* Tests the background handler's connection.
*
* @since 4.8.0
*
* @return bool
*/
public function test_connection() {
$test_url = add_query_arg( 'action', "{$this->identifier}_test", admin_url( 'admin-ajax.php' ) );
$result = wp_safe_remote_get( $test_url );
$body = ! is_wp_error( $result ) ? wp_remote_retrieve_body( $result ) : null;
// some servers may add a UTF8-BOM at the beginning of the response body, so we check if our test
// string is included in the body, as an equal check would produce a false negative test result
return $body && Helper::str_exists( $body, '[TEST_LOOPBACK]' );
}
/**
* Handles the connection test request.
*
* @since 4.8.0
*/
public function handle_connection_test_response() {
echo '[TEST_LOOPBACK]';
exit;
}
/**
* Adds the WooCommerce debug tool.
*
* @since 4.8.0
*
* @param array $tools WooCommerce core tools
* @return array
*/
public function add_debug_tool( $tools ) {
// this key is not unique to the plugin to avoid duplicate tools
$tools['sv_wc_background_job_test'] = [
'name' => __( 'Background Processing Test', 'facebook-for-woocommerce' ),
'button' => __( 'Run Test', 'facebook-for-woocommerce' ),
'desc' => __( 'This tool will test whether your server is capable of processing background jobs.', 'facebook-for-woocommerce' ),
'callback' => [ $this, 'run_debug_tool' ],
];
return $tools;
}
/**
* Runs the test connection debug tool.
*
* @since 4.8.0
*
* @return string
*/
public function run_debug_tool() {
if ( $this->test_connection() ) {
$this->debug_message = esc_html__( 'Success! You should be able to process background jobs.', 'facebook-for-woocommerce' );
$result = true;
} else {
$this->debug_message = esc_html__( 'Could not connect. Please ask your hosting company to ensure your server has loopback connections enabled.', 'facebook-for-woocommerce' );
$result = false;
}
return $result;
}
/**
* 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 4.8.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 4.8.0
*
* @return string
*/
public function get_identifier() {
return $this->identifier;
}
}