Skip to content

Instantly share code, notes, and snippets.

@mikemadisonweb
Last active March 16, 2017 09:46
Show Gist options
  • Save mikemadisonweb/506d9bc108e123a6ff3df6456acc109e to your computer and use it in GitHub Desktop.
Save mikemadisonweb/506d9bc108e123a6ff3df6456acc109e to your computer and use it in GitHub Desktop.
Abstract daemon class for console commands in Yii2
<?php
namespace console\controllers;
use yii\console\Controller;
use yii\helpers\Console;
/**
* Class AbstractDaemonController
* Abstract class to build daemon console commands
*/
abstract class AbstractDaemonController extends Controller
{
/**
* Iteration count
* 0 means infinite execution
*
* @var integer|null
*/
public $iterationMaxCount = 0;
/**
* Memory limit in Mb
* 0 means no limit
*
* @var integer|null
*/
public $memoryLimit = 0;
/**
* Stop execution on exception
*
* @var boolean
*/
public $shutdownOnException = false;
/**
* Display or not exception on command output
*
* @var boolean
*/
public $debug = false;
/**
* Tells if shutdown is requested
*
* @var boolean
*/
protected $shutdownRequested = false;
/**
* @var \Exception
*/
protected $lastException = null;
/**
* @var float
*/
protected $startTime = null;
/**
* Alows the concrete command to
* onStart an exit code
*
* @var integer
*/
protected $returnCode = 0;
/**
* Loop count
*
* @var int
*/
protected $loopCount = 0;
protected $options = [
'i' => 'iterationMaxCount',
'l' => 'memoryLimit',
's' => 'shutdownOnException',
'd' => 'debug',
];
/**
* @var array
*/
private $_callbackChain = [];
/**
* @param string $actionID
* @return array
*/
public function options($actionID)
{
return array_merge(parent::options($actionID), array_values($this->options));
}
/**
* @return array
*/
public function optionAliases()
{
return array_merge(parent::optionAliases(), $this->options);
}
public function init()
{
// Add the signal handler
if (function_exists('pcntl_signal')) {
// Enable ticks for fast signal processing
declare(ticks = 1);
pcntl_signal(SIGTERM, [$this, 'handleSignal']);
pcntl_signal(SIGINT, [$this, 'handleSignal']);
}
$this->setOptions();
return parent::init();
}
/**
* Handle process signals.
*
* @param int $signal The signalcode to handle
*/
public function handleSignal($signal)
{
switch ($signal) {
// Shutdown signals
case SIGTERM:
case SIGINT:
$this->requestShutdown();
break;
}
}
/**
* Callbacks will be executed in order they were added
* First callback set interval(execution deloy) from iteration start
* Each following callback starting to execute after interval from previous callback finish
*
* @param integer|float $interval
* @param callable $callback
* @param array $args
*/
protected function addIntervalCallback($interval, callable $callback, array $args = [])
{
if (!is_numeric($interval) || ($interval < 0)) {
throw new \InvalidArgumentException('Iteration interval must be a positive integer');
}
$this->_callbackChain[] = [
'interval' => $interval,
'callback' => $callback,
'args' => $args,
];
}
/**
* Start daemon loop execution
*
* @return integer The command exit code
*/
protected function startDaemon()
{
// Setup
$this->onStart();
if ($this->debug) {
$this->stdout("Daemon started! Planned iteration count is {$this->iterationMaxCount}.\n", Console::FG_YELLOW);
}
do {
$this->startTime = microtime(true);
try {
// Loop interval callbacks
$this->onIterationStart();
$this->callIntervalCallbacks();
} catch (StopLoopException $e) {
$this->lastException = $e;
$this->returnCode = $e->getCode();
$this->logError($e);
$this->requestShutdown();
} catch (\Exception $e) {
$this->lastException = $e;
$this->logError($e);
if ($this->debug) {
$this->stdout("Error {$e->getCode()}: {$e->getMessage()} File: {$e->getFile()} Line: {$e->getLine()}\n", Console::FG_RED);
}
if ($this->shutdownOnException) {
$this->returnCode = !(null === $e->getCode()) ? $e->getCode() : -1;
$this->requestShutdown();
}
}
if ($this->debug) {
$execTime = round(microtime(true) - $this->startTime, 2);
$this->stdout("Iteration completed in {$execTime}s\n", Console::FG_GREEN);
}
$this->loopCount++;
} while (!$this->isLastLoop());
// Prepare for shutdown
$this->onShutdown();
if ($this->debug) {
if ($this->loopCount >= $this->iterationMaxCount) {
$this->stdout("Daemon completed all iterations and shutdown gracefully.\n", Console::FG_YELLOW);
} else {
$this->stdout("Daemon stopped by user.\n", Console::FG_RED);
}
}
return $this->returnCode;
}
/**
* To be inherited
*/
protected function onStart()
{
}
/**
* To be inherited
*/
protected function onShutdown()
{
}
/**
* To be inherited
*/
protected function onIterationStart()
{
}
/**
* Instruct the command to end the endless loop gracefully.
*
* This will finish the current iteration and give the command a chance
* to cleanup.
*
* @return $this
*/
protected function requestShutdown()
{
$this->shutdownRequested = true;
return $this;
}
/**
* Is shutdown requested
*
* @return bool
*/
protected function isShutdownRequested()
{
return $this->shutdownRequested;
}
/**
* Return true after the last loop
*
* @return boolean
*/
protected function isLastLoop()
{
// Count loop
if (!(0 === $this->iterationMaxCount) && ($this->loopCount >= $this->iterationMaxCount)) {
$this->requestShutdown();
}
// Memory
if ($this->memoryLimit > 0 && memory_get_peak_usage(true) >= $this->memoryLimit * 1024 * 1024) {
$this->requestShutdown();
}
return $this->isShutdownRequested();
}
/**
* Execute callbacks after every iteration interval (can be set in fractions of a second)
*/
protected function callIntervalCallbacks()
{
foreach ($this->_callbackChain as $callback) {
if ($callback['interval'] >= 1) {
sleep($callback['interval']);
} elseif (is_numeric($callback['interval'])) {
usleep($callback['interval'] * 1000000);
}
call_user_func_array($callback['callback'], $callback['args']);
$this->cleanup();
}
}
/**
* Garbage collection
* @throws \yii\base\InvalidConfigException
* @throws \yii\db\Exception
*/
protected function cleanup()
{
clearstatcache();
if (function_exists('gc_collect_cycles')) {
gc_collect_cycles();
}
}
/**
* Reopen connection to remain fresh (prevent close by timeout) or to open connection again if there was an error
* Can be used in onStart or onShutdown callbacks in descendant class
* @throws \yii\base\InvalidConfigException
* @throws \yii\db\Exception
*/
protected function reopenConnection()
{
if (isset(\Yii::$app->db)) {
$db = \Yii::$app->db;
if ($db->getIsActive()) {
$db->close();
}
$db->open();
}
}
/**
* Set options passed by user
*/
private function setOptions()
{
$this->iterationMaxCount = (int)$this->iterationMaxCount;
if (!is_numeric($this->iterationMaxCount) || 0 > $this->iterationMaxCount) {
throw new \InvalidArgumentException("The `iterationMaxCount` option should be null or number greater than 0, {$this->iterationMaxCount} passed.");
}
$this->memoryLimit = (int)$this->memoryLimit;
if (!is_numeric($this->memoryLimit) || 0 > $this->memoryLimit) {
throw new \InvalidArgumentException("The `memoryLimit` option should be null or number greater than 0, {$this->memoryLimit} passed.");
}
if ($this->shutdownOnException === 'false') {
$this->shutdownOnException = false;
}
$this->shutdownOnException = (bool)$this->shutdownOnException;
if ($this->debug === 'false') {
$this->debug = false;
}
$this->debug = (bool)$this->debug;
}
/**
* @param \Exception $e
*/
private function logError(\Exception $e)
{
\Yii::error([
'msg' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'stacktrace' => $e->getTraceAsString(),
'startTime' => $this->startTime,
'loopCount' => $this->loopCount,
]);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment