Job.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <?php
  2. /**
  3. * @package Grav\Common\Scheduler
  4. * @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration
  5. * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Scheduler;
  9. use Cron\CronExpression;
  10. use Grav\Common\Grav;
  11. use Symfony\Component\Process\Process;
  12. class Job
  13. {
  14. use IntervalTrait;
  15. private $id;
  16. private $enabled = true;
  17. private $command;
  18. private $at;
  19. private $args = [];
  20. private $runInBackground = true;
  21. private $creationTime;
  22. private $executionTime;
  23. private $tempDir;
  24. private $lockFile;
  25. private $truthTest = true;
  26. private $output;
  27. private $returnCode = 0;
  28. private $outputTo = [];
  29. private $emailTo = [];
  30. private $emailConfig = [];
  31. private $before;
  32. private $after;
  33. private $whenOverlapping;
  34. private $outputMode;
  35. private $process;
  36. private $successful = false;
  37. private $backlink;
  38. /**
  39. * Create a new Job instance.
  40. *
  41. * @param string|callable $command
  42. * @param array $args
  43. * @param string $id
  44. */
  45. public function __construct($command, $args = [], $id = null)
  46. {
  47. if (is_string($id)) {
  48. $this->id = Grav::instance()['inflector']->hyphenize($id);
  49. } else {
  50. if (is_string($command)) {
  51. $this->id = md5($command);
  52. } else {
  53. /* @var object $command */
  54. $this->id = spl_object_hash($command);
  55. }
  56. }
  57. $this->creationTime = new \DateTime('now');
  58. // initialize the directory path for lock files
  59. $this->tempDir = sys_get_temp_dir();
  60. $this->command = $command;
  61. $this->args = $args;
  62. // Set enabled state
  63. $status = Grav::instance()['config']->get('scheduler.status');
  64. $this->enabled = !(isset($status[$id]) && $status[$id] === 'disabled');
  65. }
  66. /**
  67. * Get the command
  68. *
  69. * @return string
  70. */
  71. public function getCommand()
  72. {
  73. return $this->command;
  74. }
  75. /**
  76. * Get the cron 'at' syntax for this job
  77. *
  78. * @return string
  79. */
  80. public function getAt()
  81. {
  82. return $this->at;
  83. }
  84. /**
  85. * Get the status of this job
  86. *
  87. * @return bool
  88. */
  89. public function getEnabled()
  90. {
  91. return $this->enabled;
  92. }
  93. /**
  94. * Get optional arguments
  95. *
  96. * @return string|null
  97. */
  98. public function getArguments()
  99. {
  100. if (\is_string($this->args)) {
  101. return $this->args;
  102. }
  103. return null;
  104. }
  105. public function getCronExpression()
  106. {
  107. return CronExpression::factory($this->at);
  108. }
  109. /**
  110. * Get the status of the last run for this job
  111. *
  112. * @return bool
  113. */
  114. public function isSuccessful()
  115. {
  116. return $this->successful;
  117. }
  118. /**
  119. * Get the Job id.
  120. *
  121. * @return string
  122. */
  123. public function getId()
  124. {
  125. return $this->id;
  126. }
  127. /**
  128. * Check if the Job is due to run.
  129. * It accepts as input a DateTime used to check if
  130. * the job is due. Defaults to job creation time.
  131. * It also default the execution time if not previously defined.
  132. *
  133. * @param \DateTime $date
  134. * @return bool
  135. */
  136. public function isDue(\DateTime $date = null)
  137. {
  138. // The execution time is being defaulted if not defined
  139. if (!$this->executionTime) {
  140. $this->at('* * * * *');
  141. }
  142. $date = $date ?? $this->creationTime;
  143. return $this->executionTime->isDue($date);
  144. }
  145. /**
  146. * Check if the Job is overlapping.
  147. *
  148. * @return bool
  149. */
  150. public function isOverlapping()
  151. {
  152. return $this->lockFile &&
  153. file_exists($this->lockFile) &&
  154. call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false;
  155. }
  156. /**
  157. * Force the Job to run in foreground.
  158. *
  159. * @return $this
  160. */
  161. public function inForeground()
  162. {
  163. $this->runInBackground = false;
  164. return $this;
  165. }
  166. /**
  167. * Sets/Gets an option backlink
  168. *
  169. * @param string $link
  170. *
  171. * @return null|string
  172. */
  173. public function backlink($link = null)
  174. {
  175. if ($link) {
  176. $this->backlink = $link;
  177. }
  178. return $this->backlink;
  179. }
  180. /**
  181. * Check if the Job can run in background.
  182. *
  183. * @return bool
  184. */
  185. public function runInBackground()
  186. {
  187. return !(is_callable($this->command) || $this->runInBackground === false);
  188. }
  189. /**
  190. * This will prevent the Job from overlapping.
  191. * It prevents another instance of the same Job of
  192. * being executed if the previous is still running.
  193. * The job id is used as a filename for the lock file.
  194. *
  195. * @param string $tempDir The directory path for the lock files
  196. * @param callable $whenOverlapping A callback to ignore job overlapping
  197. * @return self
  198. */
  199. public function onlyOne($tempDir = null, callable $whenOverlapping = null)
  200. {
  201. if ($tempDir === null || !is_dir($tempDir)) {
  202. $tempDir = $this->tempDir;
  203. }
  204. $this->lockFile = implode('/', [
  205. trim($tempDir),
  206. trim($this->id) . '.lock',
  207. ]);
  208. if ($whenOverlapping) {
  209. $this->whenOverlapping = $whenOverlapping;
  210. } else {
  211. $this->whenOverlapping = function () {
  212. return false;
  213. };
  214. }
  215. return $this;
  216. }
  217. /**
  218. * Configure the job.
  219. *
  220. * @param array $config
  221. * @return self
  222. */
  223. public function configure(array $config = [])
  224. {
  225. // Check if config has defined a tempDir
  226. if (isset($config['tempDir']) && is_dir($config['tempDir'])) {
  227. $this->tempDir = $config['tempDir'];
  228. }
  229. return $this;
  230. }
  231. /**
  232. * Truth test to define if the job should run if due.
  233. *
  234. * @param callable $fn
  235. * @return self
  236. */
  237. public function when(callable $fn)
  238. {
  239. $this->truthTest = $fn();
  240. return $this;
  241. }
  242. /**
  243. * Run the job.
  244. *
  245. * @return bool
  246. */
  247. public function run()
  248. {
  249. // If the truthTest failed, don't run
  250. if ($this->truthTest !== true) {
  251. return false;
  252. }
  253. // If overlapping, don't run
  254. if ($this->isOverlapping()) {
  255. return false;
  256. }
  257. // Write lock file if necessary
  258. $this->createLockFile();
  259. // Call before if required
  260. if (is_callable($this->before)) {
  261. call_user_func($this->before);
  262. }
  263. // If command is callable...
  264. if (is_callable($this->command)) {
  265. $this->output = $this->exec();
  266. } else {
  267. $args = \is_string($this->args) ? explode(' ', $this->args) : $this->args;
  268. $command = array_merge([$this->command], $args);
  269. $process = new Process($command);
  270. $this->process = $process;
  271. if ($this->runInBackground()) {
  272. $process->start();
  273. } else {
  274. $process->run();
  275. $this->finalize();
  276. }
  277. }
  278. return true;
  279. }
  280. /**
  281. * Finish up processing the job
  282. *
  283. * @return void
  284. */
  285. public function finalize()
  286. {
  287. /** @var Process $process */
  288. $process = $this->process;
  289. if ($process) {
  290. $process->wait();
  291. if ($process->isSuccessful()) {
  292. $this->successful = true;
  293. $this->output = $process->getOutput();
  294. } else {
  295. $this->successful = false;
  296. $this->output = $process->getErrorOutput();
  297. }
  298. $this->postRun();
  299. unset($this->process);
  300. }
  301. }
  302. /**
  303. * Things to run after job has run
  304. */
  305. private function postRun()
  306. {
  307. if (count($this->outputTo) > 0) {
  308. foreach ($this->outputTo as $file) {
  309. $output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX;
  310. file_put_contents($file, $this->output, $output_mode);
  311. }
  312. }
  313. // Send output to email
  314. $this->emailOutput();
  315. // Call any callback defined
  316. if (is_callable($this->after)) {
  317. call_user_func($this->after, $this->output, $this->returnCode);
  318. }
  319. $this->removeLockFile();
  320. }
  321. /**
  322. * Create the job lock file.
  323. *
  324. * @param mixed $content
  325. * @return void
  326. */
  327. private function createLockFile($content = null)
  328. {
  329. if ($this->lockFile) {
  330. if ($content === null || !\is_string($content)) {
  331. $content = $this->getId();
  332. }
  333. file_put_contents($this->lockFile, $content);
  334. }
  335. }
  336. /**
  337. * Remove the job lock file.
  338. *
  339. * @return void
  340. */
  341. private function removeLockFile()
  342. {
  343. if ($this->lockFile && file_exists($this->lockFile)) {
  344. unlink($this->lockFile);
  345. }
  346. }
  347. /**
  348. * Execute a callable job.
  349. *
  350. * @throws \RuntimeException
  351. * @return string
  352. */
  353. private function exec()
  354. {
  355. $return_data = '';
  356. ob_start();
  357. try {
  358. $return_data = call_user_func_array($this->command, $this->args);
  359. $this->successful = true;
  360. } catch (\RuntimeException $e) {
  361. $this->successful = false;
  362. }
  363. $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : '');
  364. $this->postRun();
  365. }
  366. /**
  367. * Set the file/s where to write the output of the job.
  368. *
  369. * @param string|array $filename
  370. * @param bool $append
  371. * @return self
  372. */
  373. public function output($filename, $append = false)
  374. {
  375. $this->outputTo = is_array($filename) ? $filename : [$filename];
  376. $this->outputMode = $append === false ? 'overwrite' : 'append';
  377. return $this;
  378. }
  379. /**
  380. * Get the job output.
  381. *
  382. * @return mixed
  383. */
  384. public function getOutput()
  385. {
  386. return $this->output;
  387. }
  388. /**
  389. * Set the emails where the output should be sent to.
  390. * The Job should be set to write output to a file
  391. * for this to work.
  392. *
  393. * @param string|array $email
  394. * @return self
  395. */
  396. public function email($email)
  397. {
  398. if (!is_string($email) && !is_array($email)) {
  399. throw new \InvalidArgumentException('The email can be only string or array');
  400. }
  401. $this->emailTo = is_array($email) ? $email : [$email];
  402. // Force the job to run in foreground
  403. $this->inForeground();
  404. return $this;
  405. }
  406. /**
  407. * Email the output of the job, if any.
  408. *
  409. * @return bool
  410. */
  411. private function emailOutput()
  412. {
  413. if (!count($this->outputTo) || !count($this->emailTo)) {
  414. return false;
  415. }
  416. if (is_callable('Grav\Plugin\Email\Utils::sendEmail')) {
  417. $subject ='Grav Scheduled Job [' . $this->getId() . ']';
  418. $content = "<h1>Output from Job ID: {$this->getId()}</h1>\n<h4>Command: {$this->getCommand()}</h4><br /><pre style=\"font-size: 12px; font-family: Monaco, Consolas, monospace\">\n".$this->getOutput()."\n</pre>";
  419. $to = $this->emailTo;
  420. \Grav\Plugin\Email\Utils::sendEmail($subject, $content, $to);
  421. }
  422. return true;
  423. }
  424. /**
  425. * Set function to be called before job execution
  426. * Job object is injected as a parameter to callable function.
  427. *
  428. * @param callable $fn
  429. * @return self
  430. */
  431. public function before(callable $fn)
  432. {
  433. $this->before = $fn;
  434. return $this;
  435. }
  436. /**
  437. * Set a function to be called after job execution.
  438. * By default this will force the job to run in foreground
  439. * because the output is injected as a parameter of this
  440. * function, but it could be avoided by passing true as a
  441. * second parameter. The job will run in background if it
  442. * meets all the other criteria.
  443. *
  444. * @param callable $fn
  445. * @param bool $runInBackground
  446. * @return self
  447. */
  448. public function then(callable $fn, $runInBackground = false)
  449. {
  450. $this->after = $fn;
  451. // Force the job to run in foreground
  452. if ($runInBackground === false) {
  453. $this->inForeground();
  454. }
  455. return $this;
  456. }
  457. }