first commit

This commit is contained in:
2026-01-25 18:18:09 +08:00
commit 509312e604
8136 changed files with 2349298 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace omnilight\scheduling;
use yii\base\BootstrapInterface;
use yii\base\Application;
use yii\di\Instance;
/**
* Class Bootstrap
*/
class Bootstrap implements BootstrapInterface
{
/**
* Bootstrap method to be called during application bootstrap stage.
* @param Application $app the application currently running
*/
public function bootstrap($app)
{
if ($app instanceof \yii\console\Application) {
if (!isset($app->controllerMap['schedule'])) {
$app->controllerMap['schedule'] = 'omnilight\scheduling\ScheduleController';
}
}
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace omnilight\scheduling;
use Yii;
use yii\base\Application;
use yii\base\InvalidParamException;
use yii\mutex\Mutex;
/**
* Class CallbackEvent
*/
class CallbackEvent extends Event
{
/**
* The callback to call.
*
* @var string
*/
protected $callback;
/**
* The parameters to pass to the method.
*
* @var array
*/
protected $parameters;
/**
* Create a new event instance.
*
* @param Mutex $mutex
* @param string $callback
* @param array $parameters
* @param array $config
* @throws InvalidParamException
*/
public function __construct(Mutex $mutex, $callback, array $parameters = [], $config = [])
{
$this->callback = $callback;
$this->parameters = $parameters;
$this->_mutex = $mutex;
if (!empty($config)) {
Yii::configure($this, $config);
}
if ( ! is_string($this->callback) && ! is_callable($this->callback))
{
throw new InvalidParamException(
"Invalid scheduled callback event. Must be string or callable."
);
}
}
/**
* Run the given event.
*
* @param Application $app
* @return mixed
*/
public function run(Application $app)
{
$this->trigger(self::EVENT_BEFORE_RUN);
$response = call_user_func_array($this->callback, array_merge($this->parameters, [$app]));
parent::callAfterCallbacks($app);
$this->trigger(self::EVENT_AFTER_RUN);
return $response;
}
/**
* Do not allow the event to overlap each other.
*
* @return $this
* @throws InvalidParamException
*/
public function withoutOverlapping()
{
if (empty($this->_description)) {
throw new InvalidParamException(
"A scheduled event name is required to prevent overlapping. Use the 'description' method before 'withoutOverlapping'."
);
}
return parent::withoutOverlapping();
}
/**
* Get the mutex name for the scheduled command.
*
* @return string
*/
protected function mutexName()
{
return 'framework/schedule-' . sha1($this->_description);
}
/**
* Get the summary of the event for display.
*
* @return string
*/
public function getSummaryForDisplay()
{
if (is_string($this->_description)) return $this->_description;
return is_string($this->callback) ? $this->callback : 'Closure';
}
}

View File

@ -0,0 +1,722 @@
<?php
namespace omnilight\scheduling;
use Cron\CronExpression;
use GuzzleHttp\Client as HttpClient;
use Symfony\Component\Process\Process;
use yii\base\Application;
use yii\base\Component;
use yii\base\InvalidCallException;
use yii\base\InvalidConfigException;
use yii\mail\MailerInterface;
use yii\mutex\Mutex;
use yii\mutex\FileMutex;
/**
* Class Event
*/
class Event extends Component
{
const EVENT_BEFORE_RUN = 'beforeRun';
const EVENT_AFTER_RUN = 'afterRun';
/**
* Command string
* @var string
*/
public $command;
/**
* The cron expression representing the event's frequency.
*
* @var string
*/
protected $_expression = '* * * * * *';
/**
* The timezone the date should be evaluated on.
*
* @var \DateTimeZone|string
*/
protected $_timezone;
/**
* The user the command should run as.
*
* @var string
*/
protected $_user;
/**
* The filter callback.
*
* @var \Closure
*/
protected $_filter;
/**
* The reject callback.
*
* @var \Closure
*/
protected $_reject;
/**
* The location that output should be sent to.
*
* @var string
*/
protected $_output = null;
/**
* The string for redirection.
*
* @var array
*/
protected $_redirect = ' > ';
/**
* The array of callbacks to be run after the event is finished.
*
* @var array
*/
protected $_afterCallbacks = [];
/**
* The human readable description of the event.
*
* @var string
*/
protected $_description;
/**
* The mutex implementation.
*
* @var \yii\mutex\Mutex
*/
protected $_mutex;
/**
* Decide if errors will be displayed.
*
* @var bool
*/
protected $_omitErrors = false;
/**
* Create a new event instance.
*
* @param Mutex $mutex
* @param string $command
* @param array $config
*/
public function __construct(Mutex $mutex, $command, $config = [])
{
$this->command = $command;
$this->_mutex = $mutex;
$this->_output = $this->getDefaultOutput();
parent::__construct($config);
}
/**
* Run the given event.
* @param Application $app
*/
public function run(Application $app)
{
$this->trigger(self::EVENT_BEFORE_RUN);
if (count($this->_afterCallbacks) > 0) {
$this->runCommandInForeground($app);
} else {
$this->runCommandInBackground($app);
}
$this->trigger(self::EVENT_AFTER_RUN);
}
/**
* Get the mutex name for the scheduled command.
*
* @return string
*/
protected function mutexName()
{
return 'framework/schedule-' . sha1($this->_expression . $this->command);
}
/**
* Run the command in the foreground.
*
* @param Application $app
*/
protected function runCommandInForeground(Application $app)
{
(new Process(
trim($this->buildCommand(), '& '), dirname($app->request->getScriptFile()), null, null, null
))->run();
$this->callAfterCallbacks($app);
}
/**
* Build the comand string.
*
* @return string
*/
public function buildCommand()
{
$command = $this->command . $this->_redirect . $this->_output . (($this->_omitErrors) ? ' 2>&1 &' : '');
return $this->_user ? 'sudo -u ' . $this->_user . ' ' . $command : $command;
}
/**
* Call all of the "after" callbacks for the event.
*
* @param Application $app
*/
protected function callAfterCallbacks(Application $app)
{
foreach ($this->_afterCallbacks as $callback) {
call_user_func($callback, $app);
}
}
/**
* Run the command in the background using exec.
*
* @param Application $app
*/
protected function runCommandInBackground(Application $app)
{
chdir(dirname($app->request->getScriptFile()));
exec($this->buildCommand());
}
/**
* Determine if the given event should run based on the Cron expression.
*
* @param Application $app
* @return bool
*/
public function isDue(Application $app)
{
return $this->expressionPasses() && $this->filtersPass($app);
}
/**
* Determine if the Cron expression passes.
*
* @return bool
*/
protected function expressionPasses()
{
$date = new \DateTime('now');
if ($this->_timezone) {
$date->setTimezone($this->_timezone);
}
return CronExpression::factory($this->_expression)->isDue($date);
}
/**
* Determine if the filters pass for the event.
*
* @param Application $app
* @return bool
*/
protected function filtersPass(Application $app)
{
if ($this->_filter && !call_user_func($this->_filter, $app) ||
$this->_reject && call_user_func($this->_reject, $app)
) {
return false;
}
return true;
}
/**
* Schedule the event to run hourly.
*
* @return $this
*/
public function hourly()
{
return $this->cron('0 * * * * *');
}
/**
* The Cron expression representing the event's frequency.
*
* @param string $expression
* @return $this
*/
public function cron($expression)
{
$this->_expression = $expression;
return $this;
}
/**
* Schedule the event to run daily.
*
* @return $this
*/
public function daily()
{
return $this->cron('0 0 * * * *');
}
/**
* Schedule the command at a given time.
*
* @param string $time
* @return $this
*/
public function at($time)
{
return $this->dailyAt($time);
}
/**
* Schedule the event to run daily at a given time (10:00, 19:30, etc).
*
* @param string $time
* @return $this
*/
public function dailyAt($time)
{
$segments = explode(':', $time);
return $this->spliceIntoPosition(2, (int)$segments[0])
->spliceIntoPosition(1, count($segments) == 2 ? (int)$segments[1] : '0');
}
/**
* Splice the given value into the given position of the expression.
*
* @param int $position
* @param string $value
* @return Event
*/
protected function spliceIntoPosition($position, $value)
{
$segments = explode(' ', $this->_expression);
$segments[$position - 1] = $value;
return $this->cron(implode(' ', $segments));
}
/**
* Schedule the event to run twice daily.
*
* @return $this
*/
public function twiceDaily()
{
return $this->cron('0 1,13 * * * *');
}
/**
* Schedule the event to run only on weekdays.
*
* @return $this
*/
public function weekdays()
{
return $this->spliceIntoPosition(5, '1-5');
}
/**
* Schedule the event to run only on Mondays.
*
* @return $this
*/
public function mondays()
{
return $this->days(1);
}
/**
* Set the days of the week the command should run on.
*
* @param array|int $days
* @return $this
*/
public function days($days)
{
$days = is_array($days) ? $days : func_get_args();
return $this->spliceIntoPosition(5, implode(',', $days));
}
/**
* Schedule the event to run only on Tuesdays.
*
* @return $this
*/
public function tuesdays()
{
return $this->days(2);
}
/**
* Schedule the event to run only on Wednesdays.
*
* @return $this
*/
public function wednesdays()
{
return $this->days(3);
}
/**
* Schedule the event to run only on Thursdays.
*
* @return $this
*/
public function thursdays()
{
return $this->days(4);
}
/**
* Schedule the event to run only on Fridays.
*
* @return $this
*/
public function fridays()
{
return $this->days(5);
}
/**
* Schedule the event to run only on Saturdays.
*
* @return $this
*/
public function saturdays()
{
return $this->days(6);
}
/**
* Schedule the event to run only on Sundays.
*
* @return $this
*/
public function sundays()
{
return $this->days(0);
}
/**
* Schedule the event to run weekly.
*
* @return $this
*/
public function weekly()
{
return $this->cron('0 0 * * 0 *');
}
/**
* Schedule the event to run weekly on a given day and time.
*
* @param int $day
* @param string $time
* @return $this
*/
public function weeklyOn($day, $time = '0:0')
{
$this->dailyAt($time);
return $this->spliceIntoPosition(5, $day);
}
/**
* Schedule the event to run monthly.
*
* @return $this
*/
public function monthly()
{
return $this->cron('0 0 1 * * *');
}
/**
* Schedule the event to run yearly.
*
* @return $this
*/
public function yearly()
{
return $this->cron('0 0 1 1 * *');
}
/**
* Schedule the event to run every minute.
*
* @return $this
*/
public function everyMinute()
{
return $this->cron('* * * * * *');
}
/**
* Schedule the event to run every N minutes.
*
* @param int|string $minutes
* @return $this
*/
public function everyNMinutes($minutes)
{
return $this->cron('*/'.$minutes.' * * * * *');
}
/**
* Schedule the event to run every five minutes.
*
* @return $this
*/
public function everyFiveMinutes()
{
return $this->everyNMinutes(5);
}
/**
* Schedule the event to run every ten minutes.
*
* @return $this
*/
public function everyTenMinutes()
{
return $this->everyNMinutes(10);
}
/**
* Schedule the event to run every thirty minutes.
*
* @return $this
*/
public function everyThirtyMinutes()
{
return $this->cron('0,30 * * * * *');
}
/**
* Set the timezone the date should be evaluated on.
*
* @param \DateTimeZone|string $timezone
* @return $this
*/
public function timezone($timezone)
{
$this->_timezone = $timezone;
return $this;
}
/**
* Set which user the command should run as.
*
* @param string $user
* @return $this
*/
public function user($user)
{
$this->_user = $user;
return $this;
}
/**
* Set if errors should be displayed
*
* @param bool $omitErrors
* @return $this
*/
public function omitErrors($omitErrors = false)
{
$this->_omitErrors = $omitErrors;
return $this;
}
/**
* Do not allow the event to overlap each other.
*
* @return $this
*/
public function withoutOverlapping()
{
return $this->then(function() {
$this->_mutex->release($this->mutexName());
})->skip(function() {
return !$this->_mutex->acquire($this->mutexName());
});
}
/**
* Allow the event to only run on one server for each cron expression.
*
* @return $this
*/
public function onOneServer()
{
if ($this->_mutex instanceof FileMutex) {
throw new InvalidConfigException('You must config mutex in the application component, except the FileMutex.');
}
return $this->withoutOverlapping();
}
/**
* Register a callback to further filter the schedule.
*
* @param \Closure $callback
* @return $this
*/
public function when(\Closure $callback)
{
$this->_filter = $callback;
return $this;
}
/**
* Register a callback to further filter the schedule.
*
* @param \Closure $callback
* @return $this
*/
public function skip(\Closure $callback)
{
$this->_reject = $callback;
return $this;
}
/**
* Send the output of the command to a given location.
*
* @param string $location
* @return $this
*/
public function sendOutputTo($location)
{
$this->_redirect = ' > ';
$this->_output = $location;
return $this;
}
/**
* Append the output of the command to a given location.
*
* @param string $location
* @return $this
*/
public function appendOutputTo($location)
{
$this->_redirect = ' >> ';
$this->_output = $location;
return $this;
}
/**
* E-mail the results of the scheduled operation.
*
* @param array $addresses
* @return $this
*
* @throws \LogicException
*/
public function emailOutputTo($addresses)
{
if (is_null($this->_output) || $this->_output == $this->getDefaultOutput()) {
throw new InvalidCallException("Must direct output to a file in order to e-mail results.");
}
$addresses = is_array($addresses) ? $addresses : func_get_args();
return $this->then(function (Application $app) use ($addresses) {
$this->emailOutput($app->mailer, $addresses);
});
}
/**
* Register a callback to be called after the operation.
*
* @param \Closure $callback
* @return $this
*/
public function then(\Closure $callback)
{
$this->_afterCallbacks[] = $callback;
return $this;
}
/**
* E-mail the output of the event to the recipients.
*
* @param MailerInterface $mailer
* @param array $addresses
*/
protected function emailOutput(MailerInterface $mailer, $addresses)
{
$textBody = file_get_contents($this->_output);
if (trim($textBody) != '' ) {
$mailer->compose()
->setTextBody($textBody)
->setSubject($this->getEmailSubject())
->setTo($addresses)
->send();
}
}
/**
* Get the e-mail subject line for output results.
*
* @return string
*/
protected function getEmailSubject()
{
if ($this->_description) {
return 'Scheduled Job Output (' . $this->_description . ')';
}
return 'Scheduled Job Output';
}
/**
* Register a callback to the ping a given URL after the job runs.
*
* @param string $url
* @return $this
*/
public function thenPing($url)
{
return $this->then(function () use ($url) {
(new HttpClient)->get($url);
});
}
/**
* Set the human-friendly description of the event.
*
* @param string $description
* @return $this
*/
public function description($description)
{
$this->_description = $description;
return $this;
}
/**
* Get the summary of the event for display.
*
* @return string
*/
public function getSummaryForDisplay()
{
if (is_string($this->_description)) return $this->_description;
return $this->buildCommand();
}
/**
* Get the Cron expression for the event.
*
* @return string
*/
public function getExpression()
{
return $this->_expression;
}
public function getDefaultOutput()
{
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
return 'NUL';
} else {
return '/dev/null';
}
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace omnilight\scheduling;
use Yii;
use yii\base\Component;
use yii\base\Application;
use yii\mutex\FileMutex;
/**
* Class Schedule
*/
class Schedule extends Component
{
/**
* All of the events on the schedule.
*
* @var Event[]
*/
protected $_events = [];
/**
* The mutex implementation.
*
* @var \yii\mutex\Mutex
*/
protected $_mutex;
/**
* @var string The name of cli script
*/
public $cliScriptName = 'yii';
/**
* Schedule constructor.
* @param array $config
*/
public function __construct(array $config = [])
{
$this->_mutex = Yii::$app->has('mutex') ? Yii::$app->get('mutex') : (new FileMutex());
parent::__construct($config);
}
/**
* Add a new callback event to the schedule.
*
* @param string $callback
* @param array $parameters
* @return Event
*/
public function call($callback, array $parameters = array())
{
$this->_events[] = $event = new CallbackEvent($this->_mutex, $callback, $parameters);
return $event;
}
/**
* Add a new cli command event to the schedule.
*
* @param string $command
* @return Event
*/
public function command($command)
{
return $this->exec(PHP_BINARY . ' ' . $this->cliScriptName . ' ' . $command);
}
/**
* Add a new command event to the schedule.
*
* @param string $command
* @return Event
*/
public function exec($command)
{
$this->_events[] = $event = new Event($this->_mutex, $command);
return $event;
}
public function getEvents()
{
return $this->_events;
}
/**
* Get all of the events on the schedule that are due.
*
* @param \yii\base\Application $app
* @return Event[]
*/
public function dueEvents(Application $app)
{
return array_filter($this->_events, function(Event $event) use ($app)
{
return $event->isDue($app);
});
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace omnilight\scheduling;
use yii\console\Controller;
use yii\di\Instance;
/**
* Run the scheduled commands
*/
class ScheduleController extends Controller
{
/**
* @var Schedule
*/
public $schedule = 'schedule';
/**
* @var string Schedule file that will be used to run schedule
*/
public $scheduleFile;
/**
* @var bool set to true to avoid error output
*/
public $omitErrors = false;
public function options($actionID)
{
return array_merge(parent::options($actionID),
$actionID == 'run' ? ['scheduleFile', 'omitErrors'] : []
);
}
public function init()
{
if (\Yii::$app->has($this->schedule)) {
$this->schedule = Instance::ensure($this->schedule, Schedule::className());
} else {
$this->schedule = \Yii::createObject(Schedule::className());
}
parent::init();
}
public function actionRun()
{
$this->importScheduleFile();
$events = $this->schedule->dueEvents(\Yii::$app);
foreach ($events as $event) {
$event->omitErrors($this->omitErrors);
$this->stdout('Running scheduled command: '.$event->getSummaryForDisplay()."\n");
$event->run(\Yii::$app);
}
if (count($events) === 0)
{
$this->stdout("No scheduled commands are ready to run.\n");
}
}
protected function importScheduleFile()
{
if ($this->scheduleFile === null) {
return;
}
$scheduleFile = \Yii::getAlias($this->scheduleFile);
if (file_exists($scheduleFile) == false) {
$this->stderr('Can not load schedule file '.$this->scheduleFile."\n");
return;
}
$schedule = $this->schedule;
call_user_func(function() use ($schedule, $scheduleFile) {
include $scheduleFile;
});
}
}