Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions inc/spbc-scanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ function spbc_scanner__start()
return spbc_scanner__launch(false);
}

/**
* Checks whether the wrapper is called by a direct remote call which expects the output.
* Not the cron tick running inside the wake-up remote call: the result should be returned to the cron.
*
* @return bool
*/
function spbc_scanner__isDirectRemoteCall()
{
global $spbc_cron;

return empty($spbc_cron) && \CleantalkSP\SpbctWP\RemoteCalls::check();
}

/**
* Cron|RemoteCall wrapper function for launchBackground
*
Expand All @@ -34,7 +47,7 @@ function spbc_scanner__launch($is_cron_run = true)
{
$result = \CleantalkSP\SpbctWP\Scanner\ScannerQueue::launchBackground($is_cron_run);

if (\CleantalkSP\SpbctWP\RemoteCalls::check()) {
if (spbc_scanner__isDirectRemoteCall()) {
$result = empty($result['error'])
? 'OK'
: 'FAIL ' . die(json_encode($result));
Expand Down Expand Up @@ -82,7 +95,7 @@ function spbc_scanner__controller($transaction_id = null, $stage = null, $offset

$result = \CleantalkSP\SpbctWP\Scanner\ScannerQueue::controllerBackground($transaction_id, $stage, $offset, $amount);

if (\CleantalkSP\SpbctWP\RemoteCalls::check()) {
if (spbc_scanner__isDirectRemoteCall()) {
$result = empty($result['error'])
? 'OK'
: 'FAIL ' . die(json_encode($result));
Expand Down
30 changes: 24 additions & 6 deletions lib/CleantalkSP/Common/Cron.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ abstract class Cron

private $task_execution_min_interval;
private $cron_execution_min_interval;
private $max_tasks_per_tick;

abstract protected function getCronLastStart();

Expand All @@ -36,11 +37,13 @@ abstract protected function isIDmMatch();
*
* @param int $task_execution_min_interval Seconds
* @param int $cron_execution_min_interval Seconds
* @param int $max_tasks_per_tick Maximum tasks to run per one cron tick, 0 means unlimited
*/
public function __construct($task_execution_min_interval, $cron_execution_min_interval)
public function __construct($task_execution_min_interval, $cron_execution_min_interval, $max_tasks_per_tick = 0)
{
$this->task_execution_min_interval = $task_execution_min_interval;
$this->cron_execution_min_interval = $cron_execution_min_interval;
$this->max_tasks_per_tick = (int) $max_tasks_per_tick;

$this->tasks = static::getTasks();
$this->id = mt_rand(0, mt_getrandmax());
Expand Down Expand Up @@ -74,6 +77,7 @@ public function areThereTasksToRun()
}

$original_tasks = $this->tasks;
$due_tasks = array();
foreach ($this->tasks as $task => &$task_data) {
// Update glitched tasks
if ($task_data['processing'] === true && self::isBlockingTimeEnd($task_data['last_call'], $this->task_execution_min_interval)) {
Expand All @@ -82,14 +86,24 @@ public function areThereTasksToRun()
}

if ($task_data['processing'] === false && $task_data['next_call'] <= time()) {
$task_data['processing'] = true;
$task_data['last_call'] = time();

$this->tasks_to_run[] = $task;
$due_tasks[ $task ] = (int) $task_data['next_call'];
}
}
unset($task_data);

// The most overdue tasks go first
asort($due_tasks);
if ($this->max_tasks_per_tick > 0) {
$due_tasks = array_slice($due_tasks, 0, $this->max_tasks_per_tick, true);
}

foreach (array_keys($due_tasks) as $task) {
$this->tasks[ $task ]['processing'] = true;
$this->tasks[ $task ]['last_call'] = time();

$this->tasks_to_run[] = $task;
}

// Save tasks only if they were changed
! $this->compareSetOfTasks($original_tasks, $this->tasks) && static::saveTasks($this->tasks);

Expand Down Expand Up @@ -158,7 +172,11 @@ public function runTasks()
: time() + round($this->tasks[ $task ]['period'] / 4);
}

if (empty($this->tasks[ $task ]['next_call']) || $this->tasks[ $task ]['next_call'] < time()) {
// Overdue tasks skipped in this tick keep their next_call to be run on the next ticks
if (
isset($this->tasks_completed[ $task ]) &&
(empty($this->tasks[ $task ]['next_call']) || $this->tasks[ $task ]['next_call'] < time())
) {
$this->tasks[ $task ]['next_call'] = time() + $this->tasks[ $task ]['period'];
}
}
Expand Down
173 changes: 171 additions & 2 deletions lib/CleantalkSP/SpbctWP/Cron.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace CleantalkSP\SpbctWP;

use CleantalkSP\Variables\Server;

/**
* CleanTalk Security Cron class
*
Expand All @@ -18,10 +20,26 @@ class Cron extends \CleantalkSP\Common\Cron
const CRON_OPTION_NAME = 'spbc_cron';
const ID_OPTION = 'spbc_cron_id';
const LAST_START_OPTION = 'spbc_cron_last_start';
const WAKEUP_LAST_OPTION = 'spbc_cron_wakeup_last';
const WAKEUP_SENT_OPTION = 'spbc_cron_wakeup_sent';
const WAKEUP_FAILED_OPTION = 'spbc_cron_wakeup_failed';
const WAKEUP_RC_ACTION = 'cron_wakeup';

// Interval in seconds for restarting the task
const TASK_EXECUTION_MIN_INTERVAL = 90;
const CRON_EXECUTION_MIN_INTERVAL = 90;
// Limits the load of a single request which the cron tick shares with a site visitor
const MAX_TASKS_PER_TICK = 1;
// Limit for ticks in background requests (wake-up remote call, wp-cron.php) nobody waits for
const MAX_TASKS_PER_BACKGROUND_TICK = 3;
// Minimal interval in seconds between the background wake-ups
const WAKEUP_INTERVAL = 1200;
// The wake-up is considered failed if no tick has started within this time after sending
const WAKEUP_RESPONSE_TIMEOUT = 60;
// How long in seconds to use the local fallback tick after a failed wake-up before retrying
const WAKEUP_RETRY_INTERVAL = 86400;
// Minimal interval in seconds between the local fallback ticks on regular requests
const LOCAL_TICK_INTERVAL = 300;

public $tasks; // Array with tasks
public $tasks_to_run; // Array with tasks which should be run now
Expand All @@ -30,12 +48,163 @@ class Cron extends \CleantalkSP\Common\Cron
/**
* Cron constructor.
* Getting tasks option.
*
* @param int $max_tasks_per_tick
*/
public function __construct()
public function __construct($max_tasks_per_tick = self::MAX_TASKS_PER_TICK)
{
$this->tasks = self::getTasks();

parent::__construct(self::TASK_EXECUTION_MIN_INTERVAL, self::CRON_EXECUTION_MIN_INTERVAL);
parent::__construct(
self::TASK_EXECUTION_MIN_INTERVAL,
self::CRON_EXECUTION_MIN_INTERVAL,
$max_tasks_per_tick
);
}

/**
* Runs the cron tick in the current request.
*
* @param int $max_tasks_per_tick
*
* @return void
*/
public static function runTick($max_tasks_per_tick)
{
global $spbc_cron; // Letting know functions that they are running under spbc_cron
$spbc_cron = new self($max_tasks_per_tick);
$spbc_cron->execute();
$spbc_cron = null;
}

/**
* Handles the request where the cron tick is not allowed (GET) to keep the tasks running
* on sites with anonymous GET traffic only and not working WP cron.
* Sends the background wake-up remote call. If it is unavailable (no valid key, broken loopback)
* runs the local throttled tick with a single task instead.
*
* @return void
*/
public static function handleSkippedTick()
{
global $spbc;

$last_start = (int) get_option(self::LAST_START_OPTION, 0);
if ( ! self::isBlockingTimeEnd($last_start, self::LOCAL_TICK_INTERVAL) || ! self::hasDueTasks() ) {
return;
}

$is_wakeup_available = ! empty($spbc->key_is_ok) &&
! empty($spbc->api_key) &&
! self::isWakeupFailing($last_start);

if ( $is_wakeup_available ) {
if (
self::isBlockingTimeEnd($last_start, self::WAKEUP_INTERVAL) &&
self::claimDispatch(self::WAKEUP_INTERVAL)
) {
update_option(self::WAKEUP_SENT_OPTION, time());
RemoteCalls::performToHostNonBlocking(self::WAKEUP_RC_ACTION);
}

return;
}

if ( self::claimDispatch(self::LOCAL_TICK_INTERVAL) ) {
self::runTick(self::MAX_TASKS_PER_TICK);
}
}

/**
* @return bool
*/
private static function hasDueTasks()
{
foreach (self::getTasks() as $task_data) {
if (isset($task_data['next_call']) && $task_data['next_call'] <= time()) {
return true;
}
}

return false;
}

/**
* Checks whether the last wake-up did not lead to a tick (e.g. the loopback request is blocked).
* The failure is remembered for WAKEUP_RETRY_INTERVAL.
*
* @param int $last_start
*
* @return bool
*/
private static function isWakeupFailing($last_start)
{
$sent_at = (int) get_option(self::WAKEUP_SENT_OPTION, 0);
$failed_at = (int) get_option(self::WAKEUP_FAILED_OPTION, 0);

if (
$sent_at > $last_start &&
$failed_at < $sent_at &&
self::isBlockingTimeEnd($sent_at, self::WAKEUP_RESPONSE_TIMEOUT)
) {
$failed_at = time();
update_option(self::WAKEUP_FAILED_OPTION, $failed_at);
}

return ! self::isBlockingTimeEnd($failed_at, self::WAKEUP_RETRY_INTERVAL);
}

/**
* Atomically claims the right to dispatch the tick, so concurrent visitors don't do it in parallel.
*
* @param int $interval Seconds since the previous claim
*
* @return bool
*/
private static function claimDispatch($interval)
{
global $wpdb;

// The row should exist for the conditional update. Not autoloaded: read by the direct query only.
add_option(self::WAKEUP_LAST_OPTION, 0, '', false);

$now = time();
$claimed = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
(string) $now,
self::WAKEUP_LAST_OPTION,
$now - $interval
)
);

if ( $claimed !== 1 ) {
return false;
}

// The option was updated bypassing the cache
wp_cache_delete(self::WAKEUP_LAST_OPTION, 'options');

return true;
}

/**
* Checks whether the cron tick is allowed on the current request.
* The tick is skipped on remote calls and on regular GET page loads to not slow down visitors.
* Allowed on POST requests (AJAX, heartbeat, forms, WP loopback to wp-cron.php)
* and on any request to wp-cron.php (e.g. system cron calls it via GET).
*
* @param bool $is_remote_call
*
* @return bool
*/
public static function isTickAllowed($is_remote_call)
{
if ($is_remote_call) {
return false;
}

return Server::isPost() || spbc_wp_doing_cron();
Comment thread
AntonV1211 marked this conversation as resolved.
Resolved
}

/**
Expand Down
13 changes: 13 additions & 0 deletions lib/CleantalkSP/SpbctWP/HTTP/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,19 @@ protected function processPresets()

global $spbc;

// Send the request without waiting for the response, like WP spawn_cron() does.
// The total wait is limited by 1 second: cURL can't use a smaller timeout with the system DNS resolver.
if ( in_array('nonblocking', $this->presets, true) ) {
if ( $spbc->settings['wp__use_builtin_http_api'] ) {
$this->options['timeout'] = 0.01;
$this->options['connect_timeout'] = 1;
$this->options['blocking'] = false;
} else {
$this->options[CURLOPT_TIMEOUT] = 1;
$this->options[CURLOPT_CONNECTTIMEOUT] = 1;
}
}

if ( $spbc->settings['wp__use_builtin_http_api'] ) {
foreach ( $this->presets as $preset ) {
switch ( $preset ) {
Expand Down
52 changes: 52 additions & 0 deletions lib/CleantalkSP/SpbctWP/RemoteCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,58 @@ public static function performToHost($rc_action, $params = array(), $patterns =
);
}

/**
* Performs the remote call to the current website without waiting for the response.
* Unlike the 'async' pattern (waits up to 3 seconds), returns almost immediately like WP spawn_cron().
*
* @param string $rc_action
* @param array $params
*
* @return void
*/
public static function performToHostNonBlocking($rc_action, $params = array())
{
global $spbc;

if (function_exists('wp_is_maintenance_mode') && wp_is_maintenance_mode()) {
return;
}

$home_url = is_multisite() ? get_blog_option(get_current_blog_id(), 'home') : get_option('home');

// The result is not needed: the success is detected by the started cron tick
$http = new HTTP\Request();
$http
->setUrl($home_url)
->setData(static::buildParameters($rc_action, 'spbc', $spbc->api_key, $params))
->setPresets(array('no_cache', 'nonblocking'))
->request();
}

/**
* Runs the cron tick in the background request. Sent by Cron::wakeup().
* Responds before the tick, so the initiator (a site visitor request) does not wait for the tasks.
*
* @psalm-suppress PossiblyUnusedMethod
*/
public static function action__cron_wakeup() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
// The initiator drops the connection right after the sending
ignore_user_abort(true);

echo 'OK';
// Same as wp-cron.php. Without these SAPIs the initiator waits up to its 1-second timeout.
if ( function_exists('fastcgi_finish_request') ) {
fastcgi_finish_request();
} elseif ( function_exists('litespeed_finish_request') ) {
litespeed_finish_request();
}

SpbcCron::runTick(SpbcCron::MAX_TASKS_PER_BACKGROUND_TICK);

die();
}

/**
* @psalm-suppress PossiblyUnusedMethod
*/
Expand Down
Loading
Loading