feat(cron): add scheduled jobs configuration in admin (#59)

* feat(cron): add database schedule jobs instead of file

* feat(cron): add scheduled jobs configuration in admin (#59)

* reformat: fix codestyle (#59)

* reformat: fix codestyle (#59)

* feat: disable cron debug (#59)
This commit is contained in:
2026-02-09 19:51:16 +03:00
parent 0295a4b28b
commit c0ca0c731d
31 changed files with 947 additions and 345 deletions

View File

@@ -216,6 +216,7 @@ class ControllerExtensionModuleTgshop extends Controller
'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => (int) $this->config->get('config_language_id'),
'oc_timezone' => $this->config->get('config_timezone'),
],
'paths' => [
'images' => DIR_IMAGE,

View File

@@ -5,7 +5,6 @@ use Console\ApplicationFactory;
use Console\Commands\CacheClearCommand;
use Console\Commands\CustomerCountsCommand;
use Console\Commands\PulseSendEventsCommand;
use Console\Commands\ScheduleListCommand;
use Console\Commands\ScheduleRunCommand;
use Console\Commands\VersionCommand;
use Console\Commands\ImagesWarmupCacheCommand;
@@ -21,7 +20,7 @@ if (PHP_SAPI !== 'cli') {
}
$baseDir = __DIR__;
$debug = true;
$debug = false;
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
@@ -33,8 +32,6 @@ if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
throw new RuntimeException('Unable to locate application directory.');
}
date_default_timezone_set('UTC');
// Get Settings from Database
$host = DB_HOSTNAME;
$username = DB_USERNAME;
@@ -46,11 +43,14 @@ $dsn = "mysql:host=$host;port=$port;dbname=$dbName";
$pdo = new PDO($dsn, $username, $password);
$connection = new MySqlConnection($pdo);
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_settings'");
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
$timezone = $timezone[0]['value'] ?? 'UTC';
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
$items = Arr::mergeArraysRecursively($json, [
'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => 1,
'oc_timezone' => $timezone,
],
'paths' => [
'images' => DIR_IMAGE,
@@ -96,7 +96,6 @@ $app->boot();
$console = new Application('TeleCart', module_version());
$console->add($app->get(VersionCommand::class));
$console->add($app->get(ScheduleRunCommand::class));
$console->add($app->get(ScheduleListCommand::class));
$console->add($app->get(PulseSendEventsCommand::class));
$console->add($app->get(ImagesWarmupCacheCommand::class));
$console->add($app->get(ImagesCacheClearCommand::class));

View File

@@ -4,7 +4,9 @@ namespace Bastion\Handlers;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Bastion\Services\BotTokenConfigurator;
use Bastion\Services\CronApiKeyRegenerator;
use Bastion\Services\SettingsService;
use Carbon\Carbon;
use Exception;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
@@ -13,35 +15,56 @@ use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Support\Arr;
use Psr\Log\LoggerInterface;
class SettingsHandler
{
private BotTokenConfigurator $botTokenConfigurator;
private CronApiKeyRegenerator $cronApiKeyRegenerator;
private Settings $settings;
private SettingsService $settingsUpdateService;
private CacheInterface $cache;
private LoggerInterface $logger;
private Builder $builder;
private ConnectionInterface $connection;
private ScheduledJob $scheduledJob;
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
CronApiKeyRegenerator $cronApiKeyRegenerator,
Settings $settings,
SettingsService $settingsUpdateService,
CacheInterface $cache,
LoggerInterface $logger,
Builder $builder,
ConnectionInterface $connection
ConnectionInterface $connection,
ScheduledJob $scheduledJob
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
$this->cache = $cache;
$this->logger = $logger;
$this->builder = $builder;
$this->connection = $connection;
$this->scheduledJob = $scheduledJob;
}
/**
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
*/
public function regenerateCronScheduleUrl(Request $request): JsonResponse
{
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
$scheduleUrl = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$newApiKey
);
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
}
public function configureBotToken(Request $request): JsonResponse
@@ -81,6 +104,12 @@ class SettingsHandler
// Add CRON system details (read-only)
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
$data['cron']['last_run'] = $this->getLastCronRunDate();
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
$this->settings->get('app.shop_base_url', ''),
$this->settings->get('cron.api_key', '')
);
$data['scheduled_jobs'] = $this->scheduledJob->all();
$forms = $this->builder->newQuery()
->from('telecart_forms')
@@ -106,6 +135,21 @@ class SettingsHandler
return new JsonResponse(compact('data'));
}
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
{
$base = rtrim($shopBaseUrl, '/');
if ($base === '') {
return '';
}
$params = http_build_query([
'route' => 'extension/tgshop/handle',
'api_action' => 'runSchedule',
'api_key' => $apiKey,
]);
return $base . '/index.php?' . $params;
}
public function saveSettingsForm(Request $request): JsonResponse
{
$input = $request->json();
@@ -116,6 +160,7 @@ class SettingsHandler
if (isset($input['cron'])) {
unset($input['cron']['cli_path']);
unset($input['cron']['last_run']);
unset($input['cron']['schedule_url']);
}
$this->settingsUpdateService->update(
@@ -146,6 +191,25 @@ class SettingsHandler
]);
}
// Update scheduled jobs is_enabled and cron_expression
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
foreach ($scheduledJobs as $job) {
$id = (int) ($job['id'] ?? 0);
if ($id <= 0) {
continue;
}
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($isEnabled) {
$this->scheduledJob->enable($id);
} else {
$this->scheduledJob->disable($id);
}
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
if ($cronExpression !== '') {
$this->scheduledJob->updateCronExpression($id, $cronExpression);
}
}
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
@@ -174,7 +238,7 @@ class SettingsHandler
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
if ($lastRunTimestamp) {
return date('d.m.Y H:i:s', (int)$lastRunTimestamp);
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
}
return null;

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Bastion\Services;
use Openguru\OpenCartFramework\Config\Settings;
class CronApiKeyRegenerator
{
private Settings $settings;
private SettingsService $settingsUpdateService;
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
{
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
}
/**
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
*
* @return string новый api_key
*/
public function regenerate(): string
{
$newApiKey = bin2hex(random_bytes(32));
$all = $this->settings->getAll();
if (! isset($all['cron'])) {
$all['cron'] = [];
}
$all['cron']['api_key'] = $newApiKey;
$this->settingsUpdateService->update($all);
return $newApiKey;
}
}

View File

@@ -27,6 +27,7 @@ return [
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],

View File

@@ -117,5 +117,6 @@ HTML,
'cron' => [
'mode' => 'disabled',
'api_key' => '',
],
];

View File

@@ -1,21 +0,0 @@
<?php
/** @var \Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry $scheduler */
// Define your scheduled tasks here.
// The $scheduler variable is available in this scope.
// Example: Running a task class every 5 minutes. Class should have execute method.
// $scheduler->add(\My\Task\Class::class)->everyFiveMinutes();
// Example: Running a closure every hour
// $scheduler->add(function () {
// // Do something
// }, 'my_closure_task')->everyHour();
// Example: Custom cron expression
// $scheduler->add(\My\Task\Class::class)->at('0 12 * * *');
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
$scheduler->add(TeleCartPulseSendEventsTask::class, 'telecart_pulse_send_events')->everyTenMinutes();

View File

@@ -1,72 +0,0 @@
<?php
namespace Console\Commands;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ScheduleListCommand extends TeleCartCommand
{
private SchedulerService $schedulerService;
private Container $container;
protected static $defaultName = 'schedule:list';
protected static $defaultDescription = 'List all scheduled tasks and their status';
public function __construct(SchedulerService $schedulerService, Container $container)
{
parent::__construct();
$this->schedulerService = $schedulerService;
$this->container = $container;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$registry = new ScheduleJobRegistry($this->container);
// Load schedule config
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
if (file_exists($configFile)) {
$scheduler = $registry; // Variable name used in config file
require $configFile;
}
$jobs = $registry->getJobs();
$table = new Table($output);
$table->setHeaders(['Name / Class', 'Cron Expression', 'Last Run', 'Last Failure']);
foreach ($jobs as $job) {
$id = $job->getId();
$lastRun = $this->schedulerService->getLastRun($id);
$lastFailure = $this->schedulerService->getLastFailure($id);
$lastRunText = $lastRun ? date('Y-m-d H:i:s', $lastRun) : 'Never';
$lastFailureText = 'None';
if ($lastFailure) {
$lastFailureText = date('Y-m-d H:i:s', $lastFailure['time']) . "\n" . substr($lastFailure['message'], 0, 50);
}
$table->addRow([
$job->getName(),
// We don't have getExpression public yet on Job, assuming we might need to add it or it's not critical.
// Wait, Job class stores expression but doesn't expose it via public getter.
// I should add getExpression() to Job.php if I want to show it.
// For now, let's assume we add it.
method_exists($job, 'getExpression') ? $job->getExpression() : '???',
$lastRunText,
$lastFailureText
]);
}
$table->render();
return Command::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class ScheduleRunCommand extends TeleCartCommand
@@ -24,21 +25,34 @@ class ScheduleRunCommand extends TeleCartCommand
$this->settings = $settings;
}
protected function configure(): void
{
$this->addOption(
'ignore-global-lock',
null,
InputOption::VALUE_NONE,
'Ignore global scheduler lock (e.g. when running multiple cron instances)'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'system') {
$output->writeln('<comment>Scheduler is disabled. Skipping CLI execution.</comment>');
$output->writeln('<comment>Scheduler not in CRON mode. Skipping CLI execution.</comment>');
return Command::SUCCESS;
}
$output->writeln(sprintf(
'[%s] <info>TeleCart Scheduler Running...</info>',
Carbon::now()->toJSON(),
));
$output->writeln(
sprintf(
'[%s] <info>TeleCart Scheduler Running...</info>',
Carbon::now()->toJSON(),
)
);
$result = $this->scheduler->run();
$ignoreGlobalLock = (bool) $input->getOption('ignore-global-lock');
$result = $this->scheduler->run($ignoreGlobalLock);
// Print Executed
if (empty($result->executed)) {

View File

@@ -0,0 +1,28 @@
<?php
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS `telecart_scheduled_jobs` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`task` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL,
`is_enabled` tinyint(1) NOT NULL DEFAULT 1,
`cron_expression` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_success_at` DATETIME DEFAULT NULL,
`last_duration_seconds` FLOAT UNSIGNED NOT NULL DEFAULT 0.0,
`failed_at` DATETIME DEFAULT NULL,
`failed_reason` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
SQL;
$this->database->statement($sql);
}
};

View File

@@ -0,0 +1,21 @@
<?php
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$this->database->insert('telecart_scheduled_jobs', [
'name' => 'telecart_pulse_send_events',
'task' => TeleCartPulseSendEventsTask::class,
'is_enabled' => 0,
'cron_expression' => '*/10 * * * *',
'last_success_at' => null,
'last_duration_seconds' => 0,
'failed_at' => null,
'failed_reason' => null,
]);
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Bastion\Services\CronApiKeyRegenerator;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Migrations\Migration;
return new class extends Migration {
public function up(): void
{
$settings = $this->app->get(Settings::class);
$currentKey = $settings->get('cron.api_key', '');
if ($currentKey !== '') {
$this->logger->info('cron.api_key already set, migration skipped');
return;
}
$regenerator = $this->app->get(CronApiKeyRegenerator::class);
$regenerator->regenerate();
$this->logger->info('cron.api_key initialized');
}
};

View File

@@ -54,6 +54,9 @@ class Application extends Container implements LoggerAwareInterface
public function boot(): Application
{
$timezone = $this->getConfigValue('app.oc_timezone', 'UTC');
date_default_timezone_set($timezone);
$this->loadEnvironmentVariables();
$action = $_GET['api_action'] ?? null;

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework\Scheduler;
use Cron\CronExpression;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Container\Container;
class Job
@@ -11,14 +12,15 @@ class Job
/** @var string|callable|TaskInterface */
private $action;
private int $id;
private ?string $name;
private string $expression = '* * * * *';
private ?string $name;
public function __construct(Container $container, $action, ?string $name = null)
public function __construct(Container $container, int $id, $action, ?string $name = null)
{
$this->container = $container;
$this->id = $id;
$this->action = $action;
$this->name = $name;
}
@@ -74,6 +76,16 @@ class Job
call_user_func($this->action);
} elseif ($this->action instanceof TaskInterface) {
$this->action->execute();
} else {
$actionType = is_object($this->action) ? get_class($this->action) : gettype($this->action);
throw new InvalidArgumentException(
sprintf(
'Job "%s" (id: %d): action is not valid (expected class name, callable or TaskInterface, got %s).',
$this->getName(),
$this->id,
$actionType
)
);
}
}
@@ -90,9 +102,9 @@ class Job
return 'Closure';
}
public function getId(): string
public function getId(): int
{
return md5($this->getName());
return $this->id;
}
public function getExpression(): string

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Scheduler\Models;
use Carbon\Carbon;
use Carbon\CarbonTimeZone;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
class ScheduledJob
{
private const TABLE_NAME = 'telecart_scheduled_jobs';
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
public function all(): array
{
return $this->builder->newQuery()
->from(self::TABLE_NAME)
->get();
}
public function getEnabledScheduledJobs(): array
{
return $this->builder->newQuery()
->from(self::TABLE_NAME)
->where('is_enabled', '=', 1)
->get();
}
public function enable(int $id): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'is_enabled' => 1,
]);
}
public function disable(int $id): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'is_enabled' => 0,
]);
}
public function updateCronExpression(int $id, string $cronExpression): bool
{
return $this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'cron_expression' => mb_substr($cronExpression, 0, 64),
]);
}
public function updateLastSuccessAt(int $id, float $durationSeconds): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'last_success_at' => Carbon::now(),
'last_duration_seconds' => $durationSeconds,
]);
}
public function updateFailedAt(int $id, string $message): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'failed_at' => Carbon::now(),
'failed_reason' => mb_substr($message, 0, 1000),
]);
}
public function clearFailedInfo(int $id): void
{
$this->builder->newQuery()
->where('id', '=', $id)
->update(self::TABLE_NAME, [
'failed_at' => null,
'failed_reason' => null,
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
class ScheduleJobRegistry
{
@@ -10,10 +11,12 @@ class ScheduleJobRegistry
/** @var Job[] */
private array $jobs = [];
private ScheduledJob $scheduledJob;
public function __construct(Container $container)
public function __construct(Container $container, ScheduledJob $scheduledJob)
{
$this->container = $container;
$this->scheduledJob = $scheduledJob;
}
/**
@@ -21,9 +24,9 @@ class ScheduleJobRegistry
* @param string|null $name
* @return Job
*/
public function add($job, ?string $name = null): Job
public function add(int $id, $job, ?string $name = null): Job
{
$newJob = new Job($this->container, $job, $name);
$newJob = new Job($this->container, $id, $job, $name);
$this->jobs[] = $newJob;
return $newJob;
@@ -36,4 +39,14 @@ class ScheduleJobRegistry
{
return $this->jobs;
}
public function loadJobsFromDatabase(): void
{
$jobs = $this->scheduledJob->getEnabledScheduledJobs();
foreach ($jobs as $job) {
$this->add($job['id'], $job['task'], $job['name'])
->at($job['cron_expression']);
}
}
}

View File

@@ -1,11 +1,13 @@
<?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Scheduler;
use DateTime;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -13,23 +15,25 @@ class SchedulerService
{
private LoggerInterface $logger;
private CacheInterface $cache;
private Container $container;
private Settings $settings;
private ?ScheduleJobRegistry $registry = null;
private ScheduleJobRegistry $registry;
private const GLOBAL_LOCK_KEY = 'scheduler.global_lock';
private const GLOBAL_LOCK_TTL = 300; // 5 minutes
private ScheduledJob $scheduledJob;
public function __construct(
LoggerInterface $logger,
CacheInterface $cache,
Container $container,
Settings $settings
Settings $settings,
ScheduleJobRegistry $registry,
ScheduledJob $scheduledJob
) {
$this->logger = $logger;
$this->cache = $cache;
$this->container = $container;
$this->settings = $settings;
$this->registry = $registry;
$this->scheduledJob = $scheduledJob;
}
// For testing purposes
@@ -38,7 +42,7 @@ class SchedulerService
$this->registry = $registry;
}
public function run(): SchedulerResult
public function run(bool $ignoreGlobalLock = false): SchedulerResult
{
$result = new SchedulerResult();
@@ -49,40 +53,30 @@ class SchedulerService
return $result;
}
if ($this->isGlobalLocked()) {
if (! $ignoreGlobalLock && $this->isGlobalLocked()) {
$result->addSkipped('Global', 'Global scheduler lock active');
return $result;
}
$this->acquireGlobalLock();
// Since we want to run every 5 minutes, running it more frequently won't trigger jobs earlier than due,
// but locking might prevent overlap if previous run takes > 5 mins.
// However, updating global last run on every attempt might be useful for diagnostics,
// but strictly speaking, we only care if tasks were processed.
if (! $ignoreGlobalLock) {
$this->acquireGlobalLock();
}
$this->updateGlobalLastRun();
try {
$scheduler = $this->registry ?: new ScheduleJobRegistry($this->container);
$this->registry->loadJobsFromDatabase();
// Only load config file if registry was not injected (for production use)
if (! $this->registry) {
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
if (file_exists($configFile)) {
require $configFile;
} else {
$this->logger->warning('Scheduler config file not found: ' . $configFile);
}
}
foreach ($scheduler->getJobs() as $job) {
foreach ($this->registry->getJobs() as $job) {
$this->processJob($job, $result);
}
} catch (Throwable $e) {
$this->logger->error('Scheduler run failed: ' . $e->getMessage(), ['exception' => $e]);
$result->addFailed('Scheduler', $e->getMessage());
} finally {
$this->releaseGlobalLock();
if (! $ignoreGlobalLock) {
$this->releaseGlobalLock();
}
}
return $result;
@@ -93,50 +87,44 @@ class SchedulerService
$name = $job->getName();
$id = $job->getId();
// 1. Check if due by Cron expression
if (! $job->isDue()) {
$result->addSkipped($name, 'Not due');
return;
}
// 2. Check Last Run (Prevent running multiple times in the same minute)
if ($this->hasRanRecently($id)) {
$result->addSkipped($name, 'Already ran recently');
return;
}
// 3. Check Lock (Prevent parallel execution)
if ($this->isJobLocked($id)) {
$result->addSkipped($name, 'Job is locked (running)');
return;
}
$this->lockJob($id);
try {
// 1. Check if due by Cron expression
if (! $job->isDue()) {
$result->addSkipped($name, 'Not due');
return;
}
// 2. Check Last Run (Prevent running multiple times in the same minute)
if ($this->hasRanRecently($id)) {
$result->addSkipped($name, 'Already ran recently');
return;
}
// 3. Check Lock (Prevent parallel execution)
if ($this->isJobLocked($id)) {
$result->addSkipped($name, 'Job is locked (running)');
return;
}
// Lock and Run
$this->lockJob($id);
$this->scheduledJob->clearFailedInfo($id);
$startTime = microtime(true);
try {
$job->run();
$duration = microtime(true) - $startTime;
$this->updateLastRun($id);
$this->logger->debug("Job executed: {$name}", ['duration' => $duration]);
$result->addExecuted($name, $duration);
} catch (Throwable $e) {
$this->updateLastFailure($id, $e->getMessage());
$this->logger->error("Job failed: {$name}", ['exception' => $e]);
$result->addFailed($name, $e->getMessage());
} finally {
$this->unlockJob($id);
}
$job->run();
$duration = microtime(true) - $startTime;
$this->scheduledJob->updateLastSuccessAt($id, $duration);
$this->logger->debug("Job executed: {$name}", ['duration' => $duration]);
$result->addExecuted($name, $duration);
} catch (Throwable $e) {
$this->logger->error("Error processing job {$name}: " . $e->getMessage());
$result->addFailed($name, 'Processing error: ' . $e->getMessage());
$this->logger->error("Job failed: {$name}", ['exception' => $e]);
$this->scheduledJob->updateFailedAt($id, $e->getMessage());
$result->addFailed($name, $e->getMessage());
} finally {
$this->updateLastRun($id);
$this->unlockJob($id);
}
}
@@ -155,36 +143,36 @@ class SchedulerService
$this->cache->delete(self::GLOBAL_LOCK_KEY);
}
private function isJobLocked(string $id): bool
private function isJobLocked(int $id): bool
{
return (bool) $this->cache->get("scheduler.lock.{$id}");
}
private function lockJob(string $id): void
private function lockJob(int $id): void
{
// 30 minutes max execution time for a single job safe-guard
$this->cache->set("scheduler.lock.{$id}", 1, 1800);
}
private function unlockJob(string $id): void
private function unlockJob(int $id): void
{
$this->cache->delete("scheduler.lock.{$id}");
}
private function hasRanRecently(string $id): bool
private function hasRanRecently(int $id): bool
{
$lastRun = $this->getLastRun($id);
if (! $lastRun) {
return false;
}
$lastRunDate = (new DateTime())->setTimestamp((int) $lastRun);
$lastRunDate = (new DateTime())->setTimestamp($lastRun);
$now = new DateTime();
return $lastRunDate->format('Y-m-d H:i') === $now->format('Y-m-d H:i');
}
private function updateLastRun(string $id): void
private function updateLastRun(int $id): void
{
$this->cache->set("scheduler.last_run.{$id}", time());
}
@@ -194,34 +182,8 @@ class SchedulerService
$this->cache->set("scheduler.global_last_run", time());
}
public function getGlobalLastRun(): ?int
{
$time = $this->cache->get("scheduler.global_last_run");
return $time ? (int) $time : null;
}
private function updateLastFailure(string $id, string $message): void
{
$this->cache->set("scheduler.last_failure.{$id}", time());
$this->cache->set("scheduler.last_failure_msg.{$id}", $message);
}
public function getLastRun(string $id): ?int
public function getLastRun(int $id): ?int
{
return $this->cache->get("scheduler.last_run.{$id}");
}
public function getLastFailure(string $id): ?array
{
$time = $this->cache->get("scheduler.last_failure.{$id}");
if (! $time) {
return null;
}
return [
'time' => (int) $time,
'message' => $this->cache->get("scheduler.last_failure_msg.{$id}"),
];
}
}

View File

@@ -2,23 +2,12 @@
namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Psr\Log\LoggerInterface;
class SchedulerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(SchedulerService::class, function (Container $container) {
return new SchedulerService(
$container->get(LoggerInterface::class),
$container->get(CacheInterface::class),
$container,
$container->get(Settings::class)
);
});
//
}
}

View File

@@ -15,6 +15,7 @@ class TelegramValidateInitDataMiddleware
'health',
'etlCustomers',
'etlCustomersMeta',
'runSchedule',
];
public function __construct(SignatureValidator $signatureValidator)

View File

@@ -11,6 +11,7 @@ use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
@@ -31,6 +32,7 @@ class ApplicationFactory
RouteServiceProvider::class,
AppServiceProvider::class,
TelegramServiceProvider::class,
SchedulerServiceProvider::class,
ValidatorServiceProvider::class,
TeleCartPulseServiceProvider::class,
ImageToolServiceProvider::class,

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Handlers;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class CronHandler
{
private Settings $settings;
private SchedulerService $schedulerService;
public function __construct(Settings $settings, SchedulerService $schedulerService)
{
$this->settings = $settings;
$this->schedulerService = $schedulerService;
}
/**
* Запуск планировщика по HTTP (для cron-job.org и аналогов).
* Требует api_key в query, совпадающий с настройкой cron.api_key.
*/
public function runSchedule(Request $request): JsonResponse
{
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode !== 'cron_job_org') {
return new JsonResponse(['error' => 'Scheduler is not in cron-job.org mode'], Response::HTTP_FORBIDDEN);
}
$apiKey = $request->get('api_key', '');
$expectedKey = $this->settings->get('cron.api_key', '');
if ($expectedKey === '' || $apiKey === '' || !hash_equals($expectedKey, $apiKey)) {
return new JsonResponse(['error' => 'Invalid or missing API key'], Response::HTTP_UNAUTHORIZED);
}
// Увеличиваем лимит времени выполнения при запуске по HTTP, чтобы снизить риск timeout
$limit = 300; // 5 минут
if (function_exists('set_time_limit')) {
@set_time_limit($limit);
}
if (function_exists('ini_set')) {
@ini_set('max_execution_time', (string) $limit);
}
$result = $this->schedulerService->run(true);
$data = [
'success' => true,
'executed' => count($result->executed),
'failed' => count($result->failed),
'skipped' => count($result->skipped),
];
return new JsonResponse($data);
}
}

View File

@@ -3,6 +3,7 @@
use App\Handlers\BlocksHandler;
use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\CronHandler;
use App\Handlers\ETLHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\FormsHandler;
@@ -24,6 +25,7 @@ return [
'getForm' => [FormsHandler::class, 'getForm'],
'health' => [HealthCheckHandler::class, 'handle'],
'ingest' => [TelemetryHandler::class, 'ingest'],
'runSchedule' => [CronHandler::class, 'runSchedule'],
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
'processBlock' => [BlocksHandler::class, 'processBlock'],
'product_show' => [ProductsHandler::class, 'show'],

View File

@@ -14,11 +14,11 @@ class JobTest extends TestCase
$container = $this->app;
// Act
$job = new Job($container, function() {}, 'TestJob');
$job = new Job($container, 1, function() {}, 'TestJob');
// Assert
$this->assertEquals('TestJob', $job->getName());
$this->assertEquals(md5('TestJob'), $job->getId());
$this->assertEquals(1, $job->getId());
}
public function testJobWithoutNameUsesClassName()
@@ -27,7 +27,7 @@ class JobTest extends TestCase
$container = $this->app;
// Act
$job = new Job($container, TestTask::class);
$job = new Job($container, 1, TestTask::class);
// Assert
$this->assertEquals(TestTask::class, $job->getName());
@@ -39,7 +39,7 @@ class JobTest extends TestCase
$container = $this->app;
// Act
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Assert
$this->assertEquals('Closure', $job->getName());
@@ -49,7 +49,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Act
$job->everyMinute();
@@ -62,7 +62,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Act
$job->everyFiveMinutes();
@@ -75,7 +75,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Act
$job->everyHour();
@@ -88,7 +88,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Act
$job->dailyAt(9, 30);
@@ -101,7 +101,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Act
$job->at('0 12 * * 1');
@@ -114,7 +114,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
$job->everyMinute();
// Act
@@ -128,7 +128,7 @@ class JobTest extends TestCase
{
// Arrange
$container = $this->app;
$job = new Job($container, function() {});
$job = new Job($container, 1, function() {});
// Set to run at a specific future time
$job->at('0 23 * * *'); // 23:00 every day
@@ -146,7 +146,7 @@ class JobTest extends TestCase
$container = $this->app;
$executed = false;
$job = new Job($container, function() use (&$executed) {
$job = new Job($container, 1, function() use (&$executed) {
$executed = true;
});
@@ -167,7 +167,7 @@ class JobTest extends TestCase
return new TestTask();
});
$job = new Job($container, TestTask::class);
$job = new Job($container, 1, TestTask::class);
// Act
$job->run();
@@ -182,7 +182,7 @@ class JobTest extends TestCase
// Arrange
$container = $this->app;
$task = new TestTask();
$job = new Job($container, $task);
$job = new Job($container, 1, $task);
// Act
$job->run();

View File

@@ -2,16 +2,23 @@
namespace Tests\Unit\Framework\Scheduler;
use Mockery;
use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Tests\TestCase;
class ScheduleJobRegistryTest extends TestCase
{
private function createScheduledJobMock(): ScheduledJob
{
return Mockery::mock(ScheduledJob::class);
}
public function testRegistryCreation()
{
// Arrange & Act
$registry = new ScheduleJobRegistry($this->app);
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Assert
$this->assertInstanceOf(ScheduleJobRegistry::class, $registry);
@@ -21,10 +28,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddJobWithoutName()
{
// Arrange
$registry = new ScheduleJobRegistry($this->app);
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act
$job = $registry->add(function() {});
$job = $registry->add(1, function() {});
// Assert
$this->assertInstanceOf(Job::class, $job);
@@ -35,10 +42,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddJobWithName()
{
// Arrange
$registry = new ScheduleJobRegistry($this->app);
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act
$job = $registry->add(function() {}, 'MyCustomJob');
$job = $registry->add(1, function() {}, 'MyCustomJob');
// Assert
$this->assertInstanceOf(Job::class, $job);
@@ -49,12 +56,12 @@ class ScheduleJobRegistryTest extends TestCase
public function testAddMultipleJobs()
{
// Arrange
$registry = new ScheduleJobRegistry($this->app);
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act
$job1 = $registry->add(function() {}, 'Job1');
$job2 = $registry->add(function() {}, 'Job2');
$job3 = $registry->add(TestTask::class, 'Job3');
$job1 = $registry->add(1, function() {}, 'Job1');
$job2 = $registry->add(2, function() {}, 'Job2');
$job3 = $registry->add(3, TestTask::class, 'Job3');
// Assert
$jobs = $registry->getJobs();
@@ -67,8 +74,8 @@ class ScheduleJobRegistryTest extends TestCase
public function testGetJobsReturnsArray()
{
// Arrange
$registry = new ScheduleJobRegistry($this->app);
$registry->add(function() {}, 'TestJob');
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
$registry->add(1, function() {}, 'TestJob');
// Act
$jobs = $registry->getJobs();
@@ -82,10 +89,10 @@ class ScheduleJobRegistryTest extends TestCase
public function testJobSchedulingMethods()
{
// Arrange
$registry = new ScheduleJobRegistry($this->app);
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
// Act
$job = $registry->add(function() {}, 'TestJob')
$job = $registry->add(1, function() {}, 'TestJob')
->everyFiveMinutes();
// Assert

View File

@@ -6,6 +6,7 @@ use Mockery;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Scheduler\Job;
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
use Psr\Log\LoggerInterface;
@@ -26,11 +27,21 @@ class SchedulerServiceTest extends TestCase
$this->settingsMock = Mockery::mock(Settings::class);
$this->loggerMock = Mockery::mock(LoggerInterface::class);
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$registryMock->shouldReceive('getJobs')->andReturn([]);
$scheduledJobMock = Mockery::mock(ScheduledJob::class);
$scheduledJobMock->shouldReceive('clearFailedInfo')->zeroOrMoreTimes()->andReturnNull();
$scheduledJobMock->shouldReceive('updateLastSuccessAt')->zeroOrMoreTimes()->andReturnNull();
$scheduledJobMock->shouldReceive('updateFailedAt')->zeroOrMoreTimes()->andReturnNull();
$this->scheduler = new SchedulerService(
$this->loggerMock,
$this->cacheMock,
$this->app,
$this->settingsMock
$this->settingsMock,
$registryMock,
$scheduledJobMock
);
}
@@ -91,32 +102,33 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int'))
->once();
// Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
$jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true);
// Job has not run recently (getLastRun returns null)
$this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id')
->with('scheduler.last_run.42')
->andReturn(null);
// Job is locked
$this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->andReturn('1');
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Logger should not be called for this test
@@ -147,32 +159,33 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int'))
->once();
// Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
$jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true);
$this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->andReturn(null); // Not locked
// Job was recently executed (same minute)
$recentTime = time();
$this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id')
->with('scheduler.last_run.42')
->andReturn($recentTime);
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Inject registry for testing
@@ -200,22 +213,23 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int'))
->once();
// Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
$jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(false); // Not due
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
// Inject registry for testing
@@ -243,42 +257,43 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int'))
->once();
// Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
$jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true);
$jobMock->shouldReceive('run')->once();
$this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->andReturn(null);
$this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id')
->with('scheduler.last_run.42')
->andReturn(null); // Never ran before
// Lock and unlock operations
$this->cacheMock->shouldReceive('set')
->with('scheduler.lock.test_job_id', 1, 1800)
->with('scheduler.lock.42', 1, 1800)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.last_run.test_job_id', Mockery::type('int'))
->with('scheduler.last_run.42', Mockery::type('int'))
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
@@ -320,46 +335,43 @@ class SchedulerServiceTest extends TestCase
->with('scheduler.global_lock', 1, 300)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.global_last_run', Mockery::type('int'))
->once();
// Mock registry and job
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
$jobMock = Mockery::mock(Job::class);
$jobMock->shouldReceive('getName')->andReturn('TestJob');
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
$jobMock->shouldReceive('getId')->andReturn(42);
$jobMock->shouldReceive('isDue')->andReturn(true);
$jobMock->shouldReceive('run')->andThrow(new \Exception('Job failed'));
$this->cacheMock->shouldReceive('get')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->andReturn(null);
$this->cacheMock->shouldReceive('get')
->with('scheduler.last_run.test_job_id')
->with('scheduler.last_run.42')
->andReturn(null);
// Lock and unlock operations
$this->cacheMock->shouldReceive('set')
->with('scheduler.lock.test_job_id', 1, 1800)
->with('scheduler.lock.42', 1, 1800)
->once();
$this->cacheMock->shouldReceive('delete')
->with('scheduler.lock.test_job_id')
->with('scheduler.lock.42')
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.last_failure.test_job_id', Mockery::type('int'))
->with('scheduler.last_run.42', Mockery::type('int'))
->once();
$this->cacheMock->shouldReceive('set')
->with('scheduler.last_failure_msg.test_job_id', 'Job failed')
$this->cacheMock->shouldReceive('delete')
->with('scheduler.global_lock')
->once();
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);