feat: add scheduler module

This commit is contained in:
2025-12-06 23:47:33 +03:00
parent 389b4ab186
commit 65973d2d79
19 changed files with 1158 additions and 13 deletions

View File

@@ -72,3 +72,6 @@ test-coverage:
phar:
docker build -t telecart_local_build -f ./docker/build.dockerfile . && \
docker run -v "./src/upload/system/library/oc_telegram_shop:/build" telecart_local_build sh -c 'sh /scripts/build_phar.sh'
cli:
docker compose exec -w /module/oc_telegram_shop/upload web bash -c "/usr/local/bin/php cli.php $(ARGS)"

2
cli Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
docker compose exec -w /module/oc_telegram_shop/upload web php cli.php "$@"

View File

@@ -263,7 +263,7 @@ class ControllerExtensionModuleTgshop extends Controller
private function createLogger(bool $debug = false): Logger
{
$log = new Logger('TeleCart_Admin');
$log = new Logger('TeleCart_Admin', [], [], new DateTimeZone('UTC'));
$log->pushHandler(
new RotatingFileHandler(
DIR_LOGS . '/telecart.log', 14, $debug ? Logger::DEBUG : Logger::INFO

View File

@@ -127,7 +127,7 @@ JS;
private function createLogger(bool $appDebug = false, string $app = 'TeleCart'): LoggerInterface
{
$log = new Logger($app);
$log = new Logger($app, [], [], new DateTimeZone('UTC'));
$log->pushHandler(
new RotatingFileHandler(DIR_LOGS . '/telecart.log', 14, $appDebug ? Logger::DEBUG : Logger::INFO),
);

89
module/oc_telegram_shop/upload/cli.php Normal file → Executable file
View File

@@ -1,18 +1,95 @@
#!/usr/bin/env php
<?php
use Console\ApplicationFactory;
use Console\Commands\ScheduleListCommand;
use Console\Commands\ScheduleRunCommand;
use Console\Commands\VersionCommand;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Openguru\OpenCartFramework\QueryBuilder\Connections\MySqlConnection;
use Openguru\OpenCartFramework\Support\Arr;
use Symfony\Component\Console\Application;
if (PHP_SAPI !== 'cli') {
die("This script can only be run from CLI.\n");
}
$baseDir = __DIR__;
$debug = true;
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
if (is_readable($sysLibPath . '/oc_telegram_shop.phar')) {
require_once "phar://{$sysLibPath}/oc_telegram_shop.phar/vendor/autoload.php";
} elseif (is_dir("$basePath/oc_telegram_shop")) {
require_once "$basePath/oc_telegram_shop/vendor/autoload.php";
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
require_once $baseDir . '/../../../admin/config.php';
} elseif (is_dir("$baseDir/oc_telegram_shop")) {
require_once "$baseDir/oc_telegram_shop/vendor/autoload.php";
require_once '/web/upload/admin/config.php';
} else {
throw new RuntimeException('Unable to locate application directory.');
}
date_default_timezone_set('UTC');
// Get Settings from Database
$host = DB_HOSTNAME;
$username = DB_USERNAME;
$password = DB_PASSWORD;
$port = (int) DB_PORT;
$dbName = DB_DATABASE;
$prefix = DB_PREFIX;
$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'");
$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,
],
'paths' => [
'images' => DIR_IMAGE,
],
'logs' => [
'path' => DIR_LOGS,
],
'database' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => (int) DB_PORT,
],
'store' => [
'oc_store_id' => 0,
'oc_default_currency' => 1,
'oc_config_tax' => false,
],
'orders' => [
'oc_customer_group_id' => 1,
],
'telegram' => [
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
],
]);
// Create logger
$logger = new Logger('TeleCart_CLI', [], [], new DateTimeZone('UTC'));
$logger->pushHandler(
new RotatingFileHandler(
DIR_LOGS . '/telecart.log', 14, $debug ? Logger::DEBUG : Logger::INFO
),
);
// Creates TeleCart application.
$app = ApplicationFactory::create($items);
$app->setLogger($logger);
$app->boot();
// Creates Console and bind commands.
$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->run();

View File

@@ -6,6 +6,7 @@
"Openguru\\OpenCartFramework\\": "framework/",
"App\\": "src/",
"Bastion\\": "bastion/",
"Console\\": "console/",
"Tests\\": "tests/"
},
"files": [
@@ -32,7 +33,9 @@
"symfony/cache": "^5.4",
"vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.2",
"symfony/http-foundation": "^5.4"
"symfony/http-foundation": "^5.4",
"symfony/console": "^5.4",
"dragonmantank/cron-expression": "^3.5"
},
"require-dev": {
"doctrine/sql-formatter": "^1.3",

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "73829e240f399344756292ca05f62e89",
"content-hash": "73f60d8ed1037cbbd7e6368936ed1dc7",
"packages": [
{
"name": "brick/math",
@@ -389,6 +389,70 @@
],
"time": "2022-10-12T20:51:15+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.5.0",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
"reference": "1b2de7f4a468165dca07b142240733a1973e766d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/1b2de7f4a468165dca07b142240733a1973e766d",
"reference": "1b2de7f4a468165dca07b142240733a1973e766d",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"replace": {
"mtdowling/cron-expression": "^1.0"
},
"require-dev": {
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^1.12.32|^2.1.31",
"phpunit/phpunit": "^8.5.48|^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Cron\\": "src/Cron/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Tankersley",
"email": "chris@ctankersley.com",
"homepage": "https://github.com/dragonmantank"
}
],
"description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due",
"keywords": [
"cron",
"schedule"
],
"support": {
"issues": "https://github.com/dragonmantank/cron-expression/issues",
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.5.0"
},
"funding": [
{
"url": "https://github.com/dragonmantank",
"type": "github"
}
],
"time": "2025-10-31T18:36:32+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",
@@ -1912,6 +1976,105 @@
],
"time": "2024-09-25T14:11:13+00:00"
},
{
"name": "symfony/console",
"version": "v5.4.47",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
"reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/deprecation-contracts": "^2.1|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.9",
"symfony/polyfill-php80": "^1.16",
"symfony/service-contracts": "^1.1|^2|^3",
"symfony/string": "^5.1|^6.0"
},
"conflict": {
"psr/log": ">=3",
"symfony/dependency-injection": "<4.4",
"symfony/dotenv": "<5.1",
"symfony/event-dispatcher": "<4.4",
"symfony/lock": "<4.4",
"symfony/process": "<4.4"
},
"provide": {
"psr/log-implementation": "1.0|2.0"
},
"require-dev": {
"psr/log": "^1|^2",
"symfony/config": "^4.4|^5.0|^6.0",
"symfony/dependency-injection": "^4.4|^5.0|^6.0",
"symfony/event-dispatcher": "^4.4|^5.0|^6.0",
"symfony/lock": "^4.4|^5.0|^6.0",
"symfony/process": "^4.4|^5.0|^6.0",
"symfony/var-dumper": "^4.4|^5.0|^6.0"
},
"suggest": {
"psr/log": "For using the console logger",
"symfony/event-dispatcher": "",
"symfony/lock": "",
"symfony/process": ""
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
"keywords": [
"cli",
"command-line",
"console",
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v5.4.47"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-06T11:30:55+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v2.5.4",
@@ -2142,6 +2305,173 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
"reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Grapheme\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's grapheme_* functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"grapheme",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "3833d7255cc303546435cb650316bff708a1c75c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
"reference": "3833d7255cc303546435cb650316bff708a1c75c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Normalizer\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's Normalizer class and related functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"intl",
"normalizer",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
@@ -2532,6 +2862,92 @@
},
"time": "2019-05-28T07:50:59+00:00"
},
{
"name": "symfony/string",
"version": "v5.4.47",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "136ca7d72f72b599f2631aca474a4f8e26719799"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799",
"reference": "136ca7d72f72b599f2631aca474a4f8e26719799",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-grapheme": "~1.0",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php80": "~1.15"
},
"conflict": {
"symfony/translation-contracts": ">=3.0"
},
"require-dev": {
"symfony/error-handler": "^4.4|^5.0|^6.0",
"symfony/http-client": "^4.4|^5.0|^6.0",
"symfony/translation-contracts": "^1.1|^2",
"symfony/var-exporter": "^4.4|^5.0|^6.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Symfony\\Component\\String\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
"i18n",
"string",
"unicode",
"utf-8",
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v5.4.47"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-10T20:33:58+00:00"
},
{
"name": "symfony/translation",
"version": "v5.4.45",

View File

@@ -0,0 +1,17 @@
<?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 * * *');

View File

@@ -0,0 +1,34 @@
<?php
namespace Console;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/../configs/app.php';
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
AppServiceProvider::class,
CacheServiceProvider::class,
TelegramServiceProvider::class,
TeleCartPulseServiceProvider::class,
SchedulerServiceProvider::class,
]);
}
}

View File

@@ -0,0 +1,72 @@
<?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_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

@@ -0,0 +1,61 @@
<?php
namespace Console\Commands;
use Openguru\OpenCartFramework\Config\SettingsInterface;
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ScheduleRunCommand extends TeleCartCommand
{
private SchedulerService $scheduler;
private SettingsInterface $settings;
protected static $defaultName = 'schedule:run';
protected static $defaultDescription = 'Run scheduled commands';
public function __construct(SchedulerService $scheduler, SettingsInterface $settings)
{
parent::__construct();
$this->scheduler = $scheduler;
$this->settings = $settings;
}
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>');
return Command::SUCCESS;
}
$output->writeln('<info>TeleCart Scheduler Running...</info>');
$result = $this->scheduler->run();
// Print Executed
if (empty($result->executed)) {
$output->writeln('No tasks executed.');
} else {
foreach ($result->executed as $item) {
$output->writeln(sprintf('<info>Executed:</info> %s (%.4fs)', $item['name'], $item['duration']));
}
}
// Print Failed
foreach ($result->failed as $item) {
$output->writeln(sprintf('<error>Failed:</error> %s - %s', $item['name'], $item['error']));
}
// Print Skipped (verbose only)
if ($output->isVerbose()) {
foreach ($result->skipped as $item) {
$output->writeln(sprintf('<comment>Skipped:</comment> %s - %s', $item['name'], $item['reason']));
}
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Console\Commands;
use Symfony\Component\Console\Command\Command;
class TeleCartCommand extends Command
{
public function __construct()
{
parent::__construct();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Console\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class VersionCommand extends TeleCartCommand
{
protected static $defaultName = 'version';
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('TeleCart Version: ' . module_version());
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
use Cron\CronExpression;
use Openguru\OpenCartFramework\Container\Container;
class Job
{
private Container $container;
/** @var string|callable|TaskInterface */
private $action;
private string $expression = '* * * * *';
private ?string $name;
public function __construct(Container $container, $action, ?string $name = null)
{
$this->container = $container;
$this->action = $action;
$this->name = $name;
}
public function at(string $expression): self
{
$this->expression = $expression;
return $this;
}
public function everyMinute(): self
{
return $this->at('* * * * *');
}
public function everyFiveMinutes(): self
{
return $this->at('*/5 * * * *');
}
public function everyTenMinutes(): self
{
return $this->at('*/10 * * * *');
}
public function everyFifteenMinutes(): self
{
return $this->at('*/15 * * * *');
}
public function everyHour(): self
{
return $this->at('0 * * * *');
}
public function dailyAt(int $hour, int $minute = 0): self
{
return $this->at(sprintf('%d %d * * *', $minute, $hour));
}
public function isDue(): bool
{
return (new CronExpression($this->expression))->isDue();
}
public function run(): void
{
if (is_string($this->action) && class_exists($this->action)) {
/** @var TaskInterface $task */
$task = $this->container->get($this->action);
$task->execute();
} elseif (is_callable($this->action)) {
call_user_func($this->action);
} elseif ($this->action instanceof TaskInterface) {
$this->action->execute();
}
}
public function getName(): string
{
if ($this->name) {
return $this->name;
}
if (is_string($this->action)) {
return $this->action;
}
return 'Closure';
}
public function getId(): string
{
return md5($this->getName());
}
public function getExpression(): string
{
return $this->expression;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Container\Container;
class ScheduleJobRegistry
{
private Container $container;
/** @var Job[] */
private array $jobs = [];
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* @param string|callable|TaskInterface $job
* @param string|null $name
* @return Job
*/
public function add($job, ?string $name = null): Job
{
$newJob = new Job($this->container, $job, $name);
$this->jobs[] = $newJob;
return $newJob;
}
/**
* @return Job[]
*/
public function getJobs(): array
{
return $this->jobs;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
class SchedulerResult
{
public array $executed = [];
public array $failed = [];
public array $skipped = [];
public function addExecuted(string $name, float $duration): void
{
$this->executed[] = [
'name' => $name,
'duration' => $duration,
];
}
public function addFailed(string $name, string $error): void
{
$this->failed[] = [
'name' => $name,
'error' => $error,
];
}
public function addSkipped(string $name, string $reason): void
{
$this->skipped[] = [
'name' => $name,
'reason' => $reason,
];
}
public function toArray(): array
{
return [
'executed' => $this->executed,
'failed' => $this->failed,
'skipped' => $this->skipped,
];
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
use DateTime;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\SettingsInterface;
use Openguru\OpenCartFramework\Container\Container;
use Psr\Log\LoggerInterface;
use Throwable;
class SchedulerService
{
private LoggerInterface $logger;
private CacheInterface $cache;
private Container $container;
private SettingsInterface $settings;
private const GLOBAL_LOCK_KEY = 'scheduler.global_lock';
private const GLOBAL_LOCK_TTL = 300; // 5 minutes
public function __construct(LoggerInterface $logger, CacheInterface $cache, Container $container, SettingsInterface $settings)
{
$this->logger = $logger;
$this->cache = $cache;
$this->container = $container;
$this->settings = $settings;
}
public function run(): SchedulerResult
{
$result = new SchedulerResult();
$mode = $this->settings->get('cron.mode', 'disabled');
if ($mode === 'disabled') {
$result->addSkipped('Global', 'Scheduler is disabled');
return $result;
}
if ($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.
$this->updateGlobalLastRun();
try {
$scheduler = new ScheduleJobRegistry($this->container);
$configFile = BP_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) {
$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();
}
return $result;
}
private function processJob(Job $job, SchedulerResult $result): void
{
$name = $job->getName();
$id = $job->getId();
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);
$startTime = microtime(true);
try {
$job->run();
$duration = microtime(true) - $startTime;
$this->updateLastRun($id);
$this->logger->info("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);
}
} catch (Throwable $e) {
$this->logger->error("Error processing job {$name}: " . $e->getMessage());
$result->addFailed($name, 'Processing error: ' . $e->getMessage());
}
}
private function isGlobalLocked(): bool
{
return (bool) $this->cache->get(self::GLOBAL_LOCK_KEY);
}
private function acquireGlobalLock(): void
{
$this->cache->set(self::GLOBAL_LOCK_KEY, 1, self::GLOBAL_LOCK_TTL);
}
private function releaseGlobalLock(): void
{
$this->cache->delete(self::GLOBAL_LOCK_KEY);
}
private function isJobLocked(string $id): bool
{
return (bool) $this->cache->get("scheduler.lock.{$id}");
}
private function lockJob(string $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
{
$this->cache->delete("scheduler.lock.{$id}");
}
private function hasRanRecently(string $id): bool
{
$lastRun = $this->getLastRun($id);
if (! $lastRun) {
return false;
}
$lastRunDate = (new DateTime())->setTimestamp((int)$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
{
$this->cache->set("scheduler.last_run.{$id}", time());
}
private function updateGlobalLastRun(): void
{
$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
{
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

@@ -0,0 +1,24 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Config\SettingsInterface;
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(SettingsInterface::class)
);
});
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\Scheduler;
interface TaskInterface
{
public function execute(): void;
}