<?php
/**
* This file implements cron (scheduled tasks) handling functions.
*
* This file is part of the evoCore framework - {@link http://evocore.net/}
* See also {@link https://github.com/b2evolution/b2evolution}.
*
* @license GNU GPL v2 - {@link http://b2evolution.net/about/gnu-gpl-license}
*
* @copyright (c)2003-2020 by Francois Planque - {@link http://fplanque.com/}
*
* @package evocore
*/
if( !defined('EVO_MAIN_INIT') ) die( 'Please, do not access this page directly.' );
/**
* Log a message from cron.
* @param string Message
* @param integer Level of importance. The higher the more important.
* (if $quiet (number of "-q" params passed to cron_exec.php)
* is higher than this, the message gets skipped)
*/
function cron_log( $message, $level = 0 )
{
global $is_web, $quiet;
if( $quiet > $level )
{
return;
}
if( $is_web )
{
echo '<p>'.$message.'</p>';
}
else
{
echo "\n".$message."\n";
}
}
/**
* Append cron log and store in DB each 1 second OR 4096 bytes of the log message
*
* @param string Message text
* @param string Message type: 'success', 'warning', 'error', 'note', NULL - to use default text without addition style color
* @param string New line
*/
function cron_log_append( $message, $type = NULL, $nl = "\n" )
{
global $result_message, $cron_log_last_time, $cron_log_buffer_size, $cron_log_actions_num;
if( ! isset( $result_message ) )
{ // Initialize a var for cron log message:
$result_message = '';
}
if( ! isset( $cron_log_buffer_size ) )
{ // Initialize a var to count a cron log message length:
$cron_log_buffer_size = 0;
}
if( ! isset( $cron_log_actions_num ) )
{ // Initialize a var to count cron log actions:
$cron_log_actions_num = 0;
}
if( ! empty( $message ) )
{
// Set style for message depending on type:
switch( $type )
{
case 'success':
$message = '<span class="green">'.$message.'</span>';
break;
case 'error':
$message = '<span class="red">'.$message.'</span>';
break;
case 'warning':
$message = '<span class="orange">'.$message.'</span>';
break;
case 'note':
$message = '<span class="grey">'.$message.'</span>';
break;
}
// Append new line:
$message .= $nl;
}
// Append new message to the cron log:
$result_message .= $message;
// Update buffer size:
$cron_log_buffer_size += strlen( $message );
if( $cron_log_buffer_size >= 4096 ||
( isset( $cron_log_last_time ) && ( time() - $cron_log_last_time ) >= 1 ) )
{ // If log buffer >= 4096 OR last time execution >= 1 second:
if( $cron_log_buffer_size >= 4096 )
{ // Reset buffer size to count a next portion:
$cron_log_buffer_size = 0;
}
// We must update cron log in DB in order to don't lose it on unexpected crash:
global $DB, $ctsk_ID, $time_difference;
if( ! empty( $ctsk_ID ) )
{ // We can update only cron job which is executing right now:
$DB->query( 'UPDATE T_cron__log
SET clog_messages = '.$DB->quote( $result_message ).',
clog_actions_num = '.$DB->quote( $cron_log_actions_num ).',
clog_realstop_datetime = '.$DB->quote( date2mysql( time() + $time_difference ) ).'
WHERE clog_ctsk_ID = '.$ctsk_ID,
'Update a log message of the executing cron job #'.$ctsk_ID );
}
}
// Update a time global var to compare with next time:
$cron_log_last_time = time();
}
/**
* Count a number of cron job actions and append cron log
*
* @param string Message
* @param string Message type: 'success', 'warning', 'error', 'note'
* @param string New line
*/
function cron_log_action_end( $message, $type = NULL, $nl = "\n" )
{
global $cron_log_actions_num;
if( ! isset( $cron_log_actions_num ) )
{ // Initialize a var to count cron log actions:
$cron_log_actions_num = 0;
}
// Mark this as separate action:
$cron_log_actions_num++;
// Append cron log:
cron_log_append( $message.get_cron_log_time( $cron_log_actions_num ), $type, $nl );
}
/**
* Set a number of cron job actions.
*
* Used for manual updating the actions number when cron job has no separate
* actions, but it does many actions by single code like mysql query.
*
* @param integer Number of actions
*/
function cron_log_report_action_count( $num )
{
global $cron_log_actions_num;
$cron_log_actions_num = $num;
}
/**
* Get a time of cron log
*
* @param integer A number of cron log action
* @return string Cron log time
*/
function get_cron_log_time( $action_num = NULL )
{
global $Timer;
return '<div class="note">'
.( $action_num === NULL ? '' : 'Action #'.$action_num.' Finished. ' )
.'Elapsed time since beginning of task: '.$Timer->get_duration( 'cron_exec' ).' seconds'
.'</div>';
}
/**
* Call a cron job.
*
* @param string Key of the job
* @param string Params for the job:
* 'ctsk_ID' - task ID
* 'ctsk_name' - task name
* @return string Error message
*/
function call_job( $job_key, $job_params = array() )
{
global $DB, $inc_path, $Plugins, $admin_url, $is_web;
global $result_message, $result_status, $timestop, $time_difference;
$error_message = '';
$result_message = NULL;
$result_status = 'error';
$job_ctrl = get_cron_jobs_config( 'ctrl', $job_key );
if( preg_match( '~^plugin_(\d+)_(.*)$~', $job_ctrl, $match ) )
{ // Cron job provided by a plugin:
if( ! is_object($Plugins) )
{
load_class( 'plugins/model/_plugins.class.php', 'Plugins' );
$Plugins = new Plugins();
}
$Plugin = & $Plugins->get_by_ID( $match[1] );
if( ! $Plugin )
{
$result_message = 'Plugin for controller ['.$job_ctrl.'] could not get instantiated.';
cron_log( $result_message, 2 );
return $result_message;
}
// CALL THE PLUGIN TO HANDLE THE JOB:
$tmp_params = array( 'ctrl' => $match[2], 'params' => $job_params );
$sub_r = $Plugins->call_method( $Plugin->ID, 'ExecCronJob', $tmp_params );
$error_code = (int)$sub_r['code'];
$result_message = $sub_r['message'];
}
else
{
$controller = $inc_path.$job_ctrl;
if( ! is_file( $controller ) )
{
$result_message = 'Controller ['.$job_ctrl.'] does not exist.';
cron_log( $result_message, 2 );
return $result_message;
}
// INCLUDE THE JOB FILE AND RUN IT:
$error_code = require $controller;
}
if( is_array( $result_message ) )
{ // If result is array (we should store it as serialized data later)
// array keys: 'message' - Result message
// 'table_cols' - Columns names of the table to display on the Execution details of the cron job log
// 'table_data' - Array
$result_message_text = $result_message['message'];
}
else
{ // Result is text string
$result_message_text = $result_message;
}
if( $error_code != 1 )
{ // We got an error
$result_status = ( $error_code == 20 ? 'imap_error' : 'error' );
$result_message_text = '[Error code: '.$error_code.']'."\n".$result_message_text;
if( is_array( $result_message ) )
{ // If result is array
$result_message['message'] = $result_message_text;
}
$cron_log_level = 2;
$error_message = $result_message_text;
}
else
{
$result_status = 'finished';
$cron_log_level = 1;
}
if( $is_web )
{ // Is web interface?
$result_message_text = str_replace( "\n", "<br>\n", $result_message_text );
}
else
{ // Is CLI mode?
$result_message_text = str_replace(
array( '<br>', '<b>', '</b>', '<code>', '</code>', '≠', '≤', '≥' ),
array( "\n", '*', '*', '`', '`', '!=', '<=', '>=' ),
$result_message_text );
}
$timestop = time() + $time_difference;
cron_log( 'Task finished at '.date( 'H:i:s', $timestop ).' with status: '.$result_status
.( $is_web ? '<br>' : "\n" ).'Message: '.$result_message_text, $cron_log_level );
return $error_message;
}
/**
* Get status titles
*
* @return array
*/
function cron_statuses()
{
return array(
'pending' => T_('Pending'),
'started' => T_('Started'),
'warning' => T_('Warning'),
'timeout' => T_('Timed out'),
'error' => T_('Error'),
'imap_error' => T_('IMAP error'),
'finished' => T_('Finished'),
);
}
/**
* Get status title of sheduled job by status value
*
* @param string Status
* @return string Title
*/
function cron_status_title( $status )
{
$titles = cron_statuses();
return isset( $titles[ $status ] ) ? $titles[ $status ] : $status;
}
/**
* Get status color of sheduled job by status value
*
* @param string Status value
* @return string Color value
*/
function cron_status_color( $status )
{
$colors = array(
'pending' => '808080',
'started' => '4d77cb',
'warning' => 'dbdb57',
'timeout' => 'e09952',
'error' => 'cb4d4d',
'imap_error' => 'cb4d4d',
'finished' => '34b27d',
);
return isset( $colors[ $status ] ) ? '#'.$colors[ $status ] : 'none';
}
/**
* Get the manual page link for the requested cron job, given by the key
*
* @param string job key
* @return string Link to manual page of cron job task
*/
function cron_job_manual_link( $job_key )
{
$help_config = get_cron_jobs_config( 'help', $job_key );
if( empty( $help_config ) )
{ // There was no 'help' topic defined for this job
return '';
}
if( $help_config == '#' )
{ // The 'help' topic is set to '#', use the default 'task-' + job_key
return get_manual_link( 'task-'.$job_key );
}
if( is_url( $help_config ) )
{ // This cron job help topic is an url
return action_icon( T_('Open relevant page in online manual'), 'manual', $help_config, T_('Manual'), 5, 1, array( 'target' => '_blank', 'style' => 'vertical-align:top' ) );
}
return get_manual_link( $help_config );
}
/**
* Get name of cron job
*
* @param string Job key
* @param string Job name
* @param string|array Job params
* @return string Default value of job name of Name from DB
*/
function cron_job_name( $job_key, $job_name = '', $job_params = '' )
{
if( empty( $job_name ) )
{ // Get default name by key
$job_name = get_cron_jobs_config( 'name', $job_key );
}
$job_params = is_string( $job_params ) ? unserialize( $job_params ) : $job_params;
if( ! empty( $job_params ) )
{ // Prepare job name with the specified params
switch( $job_key )
{
case 'send-post-notifications':
// Add item title to job name
if( ! empty( $job_params['item_ID'] ) )
{
$ItemCache = & get_ItemCache();
if( $Item = $ItemCache->get_by_ID( $job_params['item_ID'], false, false ) )
{
$job_name = sprintf( $job_name, $Item->ID, $Item->get( 'title' ) );
}
}
break;
case 'send-comment-notifications':
// Add item title of the comment to job name
if( ! empty( $job_params['comment_ID'] ) )
{
$CommentCache = & get_CommentCache();
if( $Comment = & $CommentCache->get_by_ID( $job_params['comment_ID'], false, false ) )
{
if( $Item = $Comment->get_Item() )
{
$job_name = sprintf( $job_name, $Item->get( 'title' ) );
}
}
}
break;
case 'send-email-campaign':
// Add email campaign title and chunk size to job name:
global $Settings;
$email_campaign_title = '';
if( ! empty( $job_params['ecmp_ID'] ) )
{
$EmailCampaignCache = & get_EmailCampaignCache();
if( $EmailCampaign = $EmailCampaignCache->get_by_ID( $job_params['ecmp_ID'], false, false ) )
{
$email_campaign_title = $EmailCampaign->get( 'name' );
}
}
$job_name = sprintf( $job_name, $Settings->get( 'email_campaign_chunk_size' ), $email_campaign_title );
break;
}
}
return $job_name;
}
/**
* Detect timed out cron jobs and Send notifications
*
* @param array Task with error
* 'name'
* 'message'
*/
function detect_timeout_cron_jobs( $error_task = NULL )
{
global $DB, $time_difference, $admin_url;
// Convert time difference to mysql format:
$mysql_time_difference = intval( $time_difference );
if( $mysql_time_difference >= 0 )
{ // Negative value already has a sign "-", but for positive value we must add a sigh "+" for correct mysql operation below:
$mysql_time_difference = '+ '.$mysql_time_difference;
}
$SQL = new SQL( 'Find cron timeouts' );
$SQL->SELECT( 'ctsk_ID, ctsk_name, ctsk_key' );
$SQL->FROM( 'T_cron__log' );
$SQL->FROM_add( 'INNER JOIN T_cron__task ON ctsk_ID = clog_ctsk_ID' );
$SQL->FROM_add( 'LEFT JOIN T_settings ON set_name = CONCAT( "cjob_timeout_", ctsk_key )' );
$SQL->WHERE( 'clog_status = "started"' );
$SQL->WHERE_and( 'UNIX_TIMESTAMP( clog_realstart_datetime ) < UNIX_TIMESTAMP() - IFNULL( set_value, 600 ) - 120 '.$mysql_time_difference );
$SQL->GROUP_BY( 'ctsk_ID' );
$timeout_tasks = $DB->get_results( $SQL );
$tasks = array();
if( count( $timeout_tasks ) > 0 )
{
$cron_jobs_names = get_cron_jobs_config( 'name' );
foreach( $timeout_tasks as $timeout_task )
{
if( ! empty( $timeout_task->ctsk_name ) )
{ // Task name is defined in DB
$task_name = $timeout_task->ctsk_name;
}
else
{ // Try to get default task name by key:
$task_name = ( isset( $cron_jobs_names[ $timeout_task->ctsk_key ] ) ? $cron_jobs_names[ $timeout_task->ctsk_key ] : $timeout_task->ctsk_key );
}
$tasks[ $timeout_task->ctsk_ID ] = $task_name;
}
// Update timed out cron jobs:
$DB->query( 'UPDATE T_cron__log
SET clog_status = "timeout"
WHERE clog_ctsk_ID IN ( '.$DB->quote( array_keys( $tasks ) ).' )', 'Mark timeouts in cron jobs.' );
}
$timeout_tasks_num = count( $tasks );
if( $timeout_tasks_num > 0 || $error_task !== NULL )
{ // Send notification email about timed out and error cron jobs to users with edit options permission:
$email_template_params = array(
'timeout_tasks' => $tasks,
'error_task' => $error_task,
);
if( $timeout_tasks_num > 1 || ( $error_task !== NULL && $timeout_tasks_num > 0 ) )
{ // Set email subject for multiple cron jobs:
$email_subject = NT_('Errors in multiple scheduled tasks');
}
elseif( $error_task !== NULL )
{ // Use name of error task in email subject:
$email_subject = array( NT_('Error in task: %s'), $error_task['name'] );
}
else
{ // Use name of first timed out task in email subject:
foreach( $tasks as $timeout_task_name )
{
$email_subject = array( NT_('Error in task: %s'), $timeout_task_name );
break;
}
}
send_admin_notification( $email_subject, 'scheduled_task_error_report', $email_template_params );
}
}
/**
* Get config array with all available cron jobs
*
* @param string What param we should get from config: 'name', 'ctrl', 'params'
* @param string Get one cron job by specified key
* @return array|string Array of all cron jobs | Value of specified cron job
*/
function get_cron_jobs_config( $get_param = '', $get_by_key = '' )
{
global $cron_jobs_config;
if( isset( $cron_jobs_config ) )
{ // Get config from global vaiable to don't initialize this var twice
if( !empty( $get_by_key ) )
{ // A specific job param(s) was requested
if( empty( $get_param ) )
{ // Return all params
return $cron_jobs_config[ $get_by_key ];
}
// Return the requested job param if it is set or NULL otherwise
return ( isset( $cron_jobs_config[ $get_by_key ][$get_param] ) ) ? $cron_jobs_config[ $get_by_key ][$get_param] : NULL;
}
if( empty( $get_param ) )
{ // No specific param or job key was requested, return the whole config list
return $cron_jobs_config;
}
// Get a specific config param for all jobs
$restricted_config = array();
foreach( $cron_jobs_config as $job_key => $job_config )
{
$restricted_config[ $job_key ] = isset( $job_config[ $get_param ] ) ? $job_config[ $get_param ] : NULL;
}
return $restricted_config;
}
// This array will contain the modules and the plugins cron jobs data
$cron_jobs_config = array();
// Get additional jobs from different modules and Plugins:
global $modules, $Plugins;
foreach( $modules as $module )
{
$Module = & $GLOBALS[$module.'_Module'];
// Note: cron jobs with the same key will ovverride previously defined cron jobs data
$cron_jobs_config = array_merge( $cron_jobs_config, $Module->get_cron_jobs() );
}
if( !empty( $Plugins ) )
{
foreach( $Plugins->trigger_collect( 'GetCronJobs' ) as $plug_ID => $jobs )
{
if( ! is_array($jobs) )
{
$Debuglog->add( sprintf('GetCronJobs() for plugin #%d did not return array. Ignoring its jobs.', $plug_ID), array('plugins', 'error') );
continue;
}
foreach( $jobs as $job )
{
// Validate params from plugin:
if( ! isset($job['params']) )
{
$job['params'] = NULL;
}
if( ! is_array($job) || ! isset($job['ctrl'], $job['name']) )
{
$Debuglog->add( sprintf('GetCronJobs() for plugin #%d did return invalid job. Ignoring.', $plug_ID), array('plugins', 'error') );
continue;
}
if( isset($job['params']) && ! is_array($job['params']) )
{
$Debuglog->add( sprintf('GetCronJobs() for plugin #%d did return invalid job params (not an array). Ignoring.', $plug_ID), array('plugins', 'error') );
continue;
}
$job['ctrl'] = 'plugin_'.$plug_ID.'_'.$job['ctrl'];
add_cron_jobs_config( str_replace( '_', '-', $job['ctrl'] ), $job, true );
}
}
}
return get_cron_jobs_config( $get_param, $get_by_key );
}
/**
* Add new cron job to config
*
* @param string Cron job key
* @param array Cron job data, array with keys: 'name', 'ctrl', 'params'
* @param boolean TRUE to rewrite previous key
*/
function add_cron_jobs_config( $key, $data, $force = false )
{
global $cron_jobs_config;
if( ! $force && isset( $cron_jobs_config[ $key ] ))
{ // Add new cron job to config array
return;
}
$cron_jobs_config[ $key ] = $data;
}
/**
* Build cron job names SELECT query from crong_jobs_config array
*
* @param string What fields to get, separated by comma
* @return string SQL query
*/
function cron_job_sql_query( $fields = 'key,name' )
{
global $DB;
$cron_jobs_config = get_cron_jobs_config();
// We need to set the collation explicitly if the current db connection charset is utf-8 in order to avoid "Illegal mix of collation" issue
// Basically this is a hack which should be reviewed when the charset issues are fixed generally.
// TODO: asimo>Review this temporary solution after the charset issues were fixed.
$default_collation = ( $DB->is_expected_connection_charset( 'utf8' ) ) ? ' COLLATE utf8mb4_unicode_ci' : '';
$name_query = '';
if( !empty( $cron_jobs_config ) )
{
$fields = explode( ',', $fields );
$name_query = 'SELECT task_'.implode( ', task_', $fields ).' FROM ('."\n";
$first_task = true;
foreach( $cron_jobs_config as $ctsk_key => $ctsk_data )
{
$field_values = '';
$field_separator = '';
foreach( $fields as $field )
{
$field_values .= $field_separator.$DB->quote( $field == 'key' ? $ctsk_key : $ctsk_data[ $field ] ).$default_collation.' AS task_'.$field;
$field_separator = ', ';
}
if( $first_task )
{
$name_query .= "\t".'SELECT '.$field_values."\n";
$first_task = false;
}
else
{
$name_query .= "\t".'UNION SELECT '.$field_values."\n";
}
}
$name_query .= ') AS inner_temp';
}
return $name_query;
}
/**
* Error handler for cron job
*
* @return boolean FALSE - to continue normal error handler, TRUE - to don't run normal error handler
*/
function cron_job_error_handler()
{
$last_error = error_get_last();
if( $last_error['type'] === E_ERROR )
{ // If last error is fatal:
global $result_message, $error_message, $DB;
if( empty( $result_message ) )
{ // Initialize result message of current executing cron job:
$result_message = '';
}
// Append error info to cron job log:
$new_error_message = "\n".'b2evolution caught an UNEXPECTED ERROR: '
.( $last_error
? '<b>File:</b> '.$last_error['file'].', '
.'<b>Line:</b> '.$last_error['line'].', '
.'<b>Message:</b> '.$last_error['message']
: 'Unknown'
);
if( $new_error_message !== $error_message )
{ // Update only really new error, in order to exclude duplicates:
$error_message = $new_error_message;
$result_message .= $error_message;
}
// We must rollback any started transaction in order to proper update a log of the interrupted cron job:
$DB->rollback();
return true;
}
// To continue normal error handler:
return false;
}
/**
* Shutdown function to save log of the interrupted cron job by unexpected error:
*/
function cron_job_shutdown()
{
global $result_message, $ctsk_ID, $ctsk_name, $DB;
if( empty( $ctsk_ID ) )
{ // Run this function to detect only interrupted cron jobs:
return;
}
// Run error handler to store info of last error in $result_message:
cron_job_error_handler();
$DB->query( 'UPDATE T_cron__log
SET clog_status = "error",
clog_realstop_datetime = '.$DB->quote( date2mysql( time() ) ).',
clog_messages = '.$DB->quote( $result_message ).'
WHERE clog_ctsk_ID = '.$ctsk_ID,
'Record task as finished with error by shutdown function.' );
// Detect timed out tasks and send notification about timed out and error tasks:
detect_timeout_cron_jobs( array(
'ID' => $ctsk_ID,
'name' => $ctsk_name,
'message' => $result_message,
) );
}
?>