* @copyright 2021 DIGITAL RETAIL TECHNOLOGIES SL * @license https://opensource.org/licenses/MIT The MIT License * * Don't forget to prefix your containers with your own identifier * to avoid any conflicts with others containers. */ if (function_exists('date_default_timezone_set') && function_exists('date_default_timezone_get')) { date_default_timezone_set(@date_default_timezone_get()); } require_once dirname(__FILE__) . '/../../../config/config.inc.php'; require_once dirname(__FILE__) . '/../../../init.php'; require_once dirname(__FILE__) . '/../bootstrap.php'; if (!defined('_PS_VERSION_')) { exit; } /** * Class RetailcrmJobManager * * @author DIGITAL RETAIL TECHNOLOGIES SL * @license GPL * * @see https://retailcrm.ru */ class RetailcrmJobManager { const LAST_RUN_NAME = 'RETAILCRM_LAST_RUN'; const LAST_RUN_DETAIL_NAME = 'RETAILCRM_LAST_RUN_DETAIL'; const IN_PROGRESS_NAME = 'RETAILCRM_JOBS_IN_PROGRESS'; const CURRENT_TASK = 'RETAILCRM_JOB_CURRENT'; /** @var callable|null */ private static $customShutdownHandler; /** @var bool */ private static $shutdownHandlerRegistered; /** * Entry point for all jobs. * Jobs must be passed in this format: * RetailcrmJobManager::startJobs( * array( * 'jobName' => DateInterval::createFromDateString('1 hour') * ) * ); * * File `jobName.php` must exist in retailcrm/job and must contain everything to run job. * Throwed errors will be logged in /retailcrm.log * DateInterval must be positive. Pass `null` instead of DateInterval to remove * any delay - in other words, jobs without interval will be executed every time. * * @param array $jobs Jobs list * * @throws \Exception */ public static function startJobs( $jobs = [] ) { RetailcrmLogger::writeDebug(__METHOD__, 'starting JobManager'); static::execJobs($jobs); } /** * Run scheduled jobs with request * * @param array $jobs * * @throws \Exception */ public static function execJobs($jobs = []) { $current = date_create_immutable('now'); $lastRuns = []; $lastRunsDetails = []; try { $lastRuns = static::getLastRuns(); $lastRunsDetails = static::getLastRunDetails(); } catch (Exception $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), '', $jobs ); return; } catch (Error $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), '', $jobs ); return; } RetailcrmLogger::writeDebug(__METHOD__, 'Trying to acquire lock...'); if (!static::lock()) { RetailcrmLogger::writeDebug(__METHOD__, 'Cannot acquire lock'); die; } RetailcrmLogger::writeDebug( __METHOD__, sprintf('Current time: %s', $current->format(DATE_RFC3339)) ); foreach ($lastRuns as $name => $diff) { if (!array_key_exists($name, $jobs)) { unset($lastRuns[$name]); } } uasort($jobs, function ($diff1, $diff2) { $date1 = new \DateTimeImmutable(); $date2 = new \DateTimeImmutable(); if (null !== $diff1) { $date1 = $date1->add($diff1); } if (null !== $diff2) { $date2 = $date2->add($diff2); } if ($date1 == $date2) { return 0; } return ($date1 > $date2) ? -1 : 1; }); foreach ($jobs as $job => $diff) { $exception = null; try { if (isset($lastRuns[$job]) && $lastRuns[$job] instanceof DateTimeImmutable) { $shouldRunAt = clone $lastRuns[$job]; if ($diff instanceof DateInterval) { $shouldRunAt = $shouldRunAt->add($diff); } } else { $shouldRunAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00'); } RetailcrmLogger::writeDebug(__METHOD__, sprintf( 'Checking %s, interval %s, shouldRunAt: %s: %s', $job, null === $diff ? 'NULL' : $diff->format('%R%Y-%m-%d %H:%i:%s:%F'), isset($shouldRunAt) && $shouldRunAt instanceof \DateTimeImmutable ? $shouldRunAt->format(DATE_RFC3339) : 'undefined', (isset($shouldRunAt) && $shouldRunAt <= $current) ? 'true' : 'false' )); if (isset($shouldRunAt) && $shouldRunAt <= $current) { RetailcrmLogger::writeDebug(__METHOD__, sprintf('Executing job %s', $job)); $result = RetailcrmJobManager::runJob($job); RetailcrmLogger::writeDebug( __METHOD__, sprintf('Executed job %s, result: %s', $job, $result ? 'true' : 'false') ); $lastRuns[$job] = new \DateTimeImmutable('now'); if ($result) { $lastRunsDetails[$job] = [ 'success' => true, 'lastRun' => new \DateTimeImmutable('now'), 'error' => null, ]; self::clearCurrentJob($job); } break; } } catch (Exception $e) { $exception = $e; } catch (Error $e) { $exception = $e; } if (null !== $exception) { if ($exception instanceof RetailcrmJobManagerException && $exception->getPrevious() instanceof \Exception ) { $exception = $exception->getPrevious(); } $lastRunsDetails[$job] = [ 'success' => false, 'lastRun' => new \DateTimeImmutable('now'), 'error' => [ 'message' => $exception->getMessage(), 'trace' => $exception->getTraceAsString(), ], ]; static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), $job ); self::clearCurrentJob($job); } } try { static::setLastRuns($lastRuns); static::setLastRunDetails($lastRunsDetails); } catch (Exception $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), '', $jobs ); } catch (Error $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), '', $jobs ); } static::unlock(); } /** * Run job in the force mode so it will run even if there's another job running * * @param $jobName * * @return bool * * @throws Exception * @throws Error */ public static function execManualJob($jobName) { try { $result = static::runJob($jobName, false, true, Shop::getContextShopID()); if ($result) { static::updateLastRunDetail($jobName, [ 'success' => true, 'lastRun' => new \DateTimeImmutable('now'), 'error' => null, ]); } return $result; } catch (Exception $exception) { self::handleManualRunError($jobName, $exception); } catch (Error $exception) { self::handleManualRunError($jobName, $exception); } return false; } /** * Extracts jobs last runs from db * * @return array * * @throws \Exception */ private static function getLastRuns() { $lastRuns = json_decode((string) Configuration::getGlobalValue(self::LAST_RUN_NAME), true); if (JSON_ERROR_NONE != json_last_error() || !is_array($lastRuns)) { $lastRuns = []; } else { foreach ($lastRuns as $job => $ran) { $lastRan = DateTimeImmutable::createFromFormat(DATE_RFC3339, $ran); if ($lastRan instanceof DateTimeImmutable) { $lastRuns[$job] = $lastRan; } else { $lastRuns[$job] = new DateTimeImmutable(); } } } return (array) $lastRuns; } /** * Updates jobs last runs in db * * @param array $lastRuns * * @throws \Exception */ private static function setLastRuns($lastRuns = []) { $now = new DateTimeImmutable(); if (!is_array($lastRuns)) { $lastRuns = []; } foreach ($lastRuns as $job => $ran) { if ($ran instanceof DateTimeImmutable) { $lastRuns[$job] = $ran->format(DATE_RFC3339); } else { $lastRuns[$job] = $now->format(DATE_RFC3339); } RetailcrmLogger::writeDebug( __METHOD__, sprintf('Saving last run for %s as %s', $job, $lastRuns[$job]) ); } Configuration::updateGlobalValue(self::LAST_RUN_NAME, (string) json_encode($lastRuns)); } /** * @param string $jobName * @param Datetime|null $data * * @throws Exception */ public static function updateLastRun($jobName, $data) { $lastRuns = static::getLastRuns(); $lastRuns[$jobName] = $data; static::setLastRuns($lastRuns); } /** * Extracts jobs last runs from db * * @param bool $withFormattedDate * * @return array */ public static function getLastRunDetails($withFormattedDate = false) { $lastRuns = json_decode((string) Configuration::getGlobalValue(self::LAST_RUN_DETAIL_NAME), true); if (JSON_ERROR_NONE != json_last_error() || !is_array($lastRuns)) { $lastRuns = []; } else { foreach ($lastRuns as $job => $details) { $lastRan = DateTimeImmutable::createFromFormat(DATE_RFC3339, $details['lastRun']); if ($lastRan instanceof DateTimeImmutable) { $lastRuns[$job]['lastRun'] = $withFormattedDate ? $lastRan->format('Y-m-d H:i:s') : $lastRan; } else { $lastRuns[$job]['lastRun'] = null; } } } return (array) $lastRuns; } /** * Updates jobs last runs in db * * @param array $lastRuns * * @throws \Exception */ private static function setLastRunDetails($lastRuns = []) { if (!is_array($lastRuns)) { $lastRuns = []; } foreach ($lastRuns as $job => $details) { if (isset($details['lastRun']) && $details['lastRun'] instanceof DateTimeImmutable) { $lastRuns[$job]['lastRun'] = $details['lastRun']->format(DATE_RFC3339); } else { $lastRuns[$job]['lastRun'] = null; } } RetailcrmLogger::writeDebug(__METHOD__, json_encode($lastRuns)); Configuration::updateGlobalValue(self::LAST_RUN_DETAIL_NAME, (string) json_encode($lastRuns)); } /** * @param string $jobName * @param array $data * * @throws Exception */ public static function updateLastRunDetail($jobName, $data) { $lastRunsDetails = static::getLastRunDetails(); $lastRunsDetails[$jobName] = $data; static::setLastRunDetails($lastRunsDetails); } /** * Runs job * * @param string $job * @param bool $once * @param bool $cliMode * @param bool $force * @param int $shopId * * @return bool * * @throws \RetailcrmJobManagerException */ public static function runJob($job, $cliMode = false, $force = false, $shopId = null) { $jobName = self::escapeJobName($job); try { return static::execHere($jobName, $cliMode, $force, $shopId); } catch (\RetailcrmJobManagerException $exception) { throw $exception; } catch (Exception $exception) { throw new RetailcrmJobManagerException($exception->getMessage(), $job, [], 0, $exception); } catch (Error $exception) { throw new RetailcrmJobManagerException($exception->getMessage(), $job, [], 0, $exception); } } /** * Serializes jobs to JSON * * @param $jobs * * @return string */ public static function serializeJobs($jobs) { foreach ($jobs as $name => $interval) { $jobs[$name] = serialize($interval); } return (string) base64_encode(json_encode($jobs)); } /** * Sets current running job. Every job must call this in order to work properly. * Current running job will be cleared automatically after job was finished (or crashed). * That way, JobManager will maintain it's data integrity and will coexist with manual runs and cron. * * @param string $job * * @return bool */ public static function setCurrentJob($job) { return (bool) Configuration::updateGlobalValue(self::CURRENT_TASK, $job); } /** * Returns current job or empty string if there's no jobs running at this moment * * @return string */ public static function getCurrentJob() { return (string) Configuration::getGlobalValue(self::CURRENT_TASK); } /** * Clears current job (job name must be provided to ensure we're removed correct job). * * @param string|null $job * * @return bool */ public static function clearCurrentJob($job) { if (null === $job || self::getCurrentJob() == $job) { return Configuration::deleteByName(self::CURRENT_TASK); } return true; } /** * Resets JobManager internal state. Doesn't work if JobManager is active. * * @return bool * * @throws \Exception */ public static function reset() { $result = Configuration::deleteByName(self::CURRENT_TASK); $result = $result && Configuration::deleteByName(self::LAST_RUN_NAME); self::unlock(); return $result; } /** * Sets custom shutdown handler, it will be called before calling default shutdown handler. * * @param callable $shutdownHandler */ public static function setCustomShutdownHandler($shutdownHandler) { if (is_callable($shutdownHandler)) { self::$customShutdownHandler = $shutdownHandler; } } /** * Wrapper for shutdown handler. Moved here in order to keep compatibility with older PHP versions. */ public static function shutdownHandlerWrapper() { $error = error_get_last(); if (null !== $error && E_ERROR === $error['type']) { self::defaultShutdownHandler($error); } } /** * Register default shutdown handler (should be be called before any job execution) */ private static function registerShutdownHandler() { if (!self::$shutdownHandlerRegistered) { register_shutdown_function(['RetailcrmJobManager', 'shutdownHandlerWrapper']); self::$shutdownHandlerRegistered = true; } } /** * Default handler for shutdown function * * @param array $error */ private static function defaultShutdownHandler($error) { if (is_callable(self::$customShutdownHandler)) { call_user_func_array(self::$customShutdownHandler, [$error]); } else { if (null !== $error) { $job = self::getCurrentJob(); if (!empty($job)) { $lastRunsDetails = self::getLastRunDetails(); $lastRunsDetails[$job] = [ 'success' => false, 'lastRun' => new \DateTimeImmutable('now'), 'error' => [ 'message' => (isset($error['message']) ? $error['message'] : print_r($error, true)), 'trace' => print_r($error, true), ], ]; try { self::setLastRunDetails($lastRunsDetails); } catch (Exception $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), $job ); } catch (Error $exception) { static::handleError( $exception->getFile(), $exception->getMessage(), $exception->getTraceAsString(), $job ); } } self::clearCurrentJob(null); } } RetailcrmLogger::writeCaller( __METHOD__, 'Warning: something disrupted correct process execution. All information will be provided here.' ); RetailcrmLogger::writeCaller(__METHOD__, print_r($error, true)); self::unlock(); exit(1); } /** * Writes error to log and returns 500 * * @param string $file * @param string $msg * @param string $trace * @param string $currentJob * @param array $jobs */ private static function handleError($file, $msg, $trace, $currentJob = '', $jobs = []) { $data = []; if (!empty($currentJob)) { $data[] = 'current job: ' . $currentJob; } if (0 < count($jobs)) { $data[] = 'jobs list: ' . self::serializeJobs($jobs); } RetailcrmLogger::writeNoCaller(sprintf('%s: %s (%s)', $file, $msg, implode(', ', $data))); RetailcrmLogger::writeNoCaller($trace); if (PHP_SAPI != 'cli' && !headers_sent()) { RetailcrmTools::http_response_code(500); } } /** * Executes job without hanging up request (if executed by a hit). * Returns execution result from job. * * @param string $jobName * @param string $phpScript * @param bool $once * @param bool $cliMode * @param bool $force * @param int $shopId * * @return bool * * @throws \RetailcrmJobManagerException */ private static function execHere($jobName, $cliMode = false, $force = false, $shopId = null) { set_time_limit(static::getTimeLimit()); if (!$cliMode && !$force) { ignore_user_abort(true); if (version_compare(phpversion(), '7.0.16', '>=') && function_exists('fastcgi_finish_request') ) { if (!headers_sent()) { header('Expires: Thu, 19 Nov 1981 08:52:00 GMT'); header('Cache-Control: no-store, no-cache, must-revalidate'); } fastcgi_finish_request(); } } if (!class_exists($jobName)) { throw new \RetailcrmJobManagerException(sprintf( 'The job class "%s" was not found.', $jobName )); } $job = new $jobName(); if (!($job instanceof RetailcrmEventInterface)) { throw new \RetailcrmJobManagerException(sprintf( 'Class "%s" must implement RetailcrmEventInterface', $jobName )); } $job->setCliMode($cliMode); $job->setForce($force); $job->setShopId($shopId); self::registerShutdownHandler(); return $job->execute(); } /** * Returns script execution time limit * * @return int */ private static function getTimeLimit() { return 14400; } /** * Removes disallowed symbols from job name. Only latin characters, numbers and underscore allowed. * * @param string $job * * @return string */ private static function escapeJobName($job) { return (string) preg_replace('/[^[a-zA-Z0-9_]]*/m', '', $job); } /** * Returns when JobManager was executed * * @throws \Exception */ private static function getLastRun() { $lastRuns = array_values(static::getLastRuns()); if (empty($lastRuns)) { return \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1970-01-01 00:00:00'); } usort( $lastRuns, function ($first, $second) { if ($first < $second) { return 1; } elseif ($first > $second) { return -1; } else { return 0; } } ); return $lastRuns[count($lastRuns) - 1]; } /** * Returns true if lock is present and it's not expired * * @return bool * * @throws \Exception */ private static function isLocked() { $inProcess = (bool) Configuration::getGlobalValue(self::IN_PROGRESS_NAME); $lastRan = static::getLastRun(); $lastRanSeconds = $lastRan->format('U'); if ($inProcess && ($lastRanSeconds + self::getTimeLimit()) < time()) { RetailcrmLogger::writeDebug(__METHOD__, 'Removing lock because time limit exceeded.'); static::unlock(); return false; } return $inProcess; } /** * Installs lock * * @return bool * * @throws \Exception */ private static function lock() { if (!static::isLocked()) { RetailcrmLogger::writeDebug(__METHOD__, 'Acquiring lock...'); Configuration::updateGlobalValue(self::IN_PROGRESS_NAME, true); RetailcrmLogger::writeDebug(__METHOD__, 'Lock acquired.'); return true; } return false; } /** * Removes lock * * @return bool */ private static function unlock() { RetailcrmLogger::writeDebug(__METHOD__, 'Removing lock...'); Configuration::updateGlobalValue(self::IN_PROGRESS_NAME, false); RetailcrmLogger::writeDebug(__METHOD__, 'Lock removed.'); return false; } /** * @param $jobName * @param Exception|Error $exception * * @throws Exception|Error */ private static function handleManualRunError($jobName, $exception) { if ($exception instanceof RetailcrmJobManagerException && ( $exception->getPrevious() instanceof Exception || $exception->getPrevious() instanceof Error ) ) { $exception = $exception->getPrevious(); } RetailcrmLogger::printException($exception, '', false); self::updateLastRunDetail($jobName, [ 'success' => false, 'lastRun' => new \DateTimeImmutable('now'), 'error' => [ 'message' => $exception->getMessage(), 'trace' => $exception->getTraceAsString(), ], ]); throw $exception; } /** * Returns list of jobs which are allowed to be executed via admin panel * * @return string[] */ public static function getAllowedJobs() { return [ 'RetailcrmAbandonedCartsEvent', 'RetailcrmIcmlEvent', 'RetailcrmIcmlUpdateUrlEvent', 'RetailcrmSyncEvent', 'RetailcrmInventoriesEvent', 'RetailcrmUpdateSinceIdEvent', 'RetailcrmClearLogsEvent', ]; } }