├── README.md ├── composer.json └── src └── Shell └── SchedulerShell.php /README.md: -------------------------------------------------------------------------------- 1 | CakePHP Scheduler Plugin 2 | ======================== 3 | 4 | Makes scheduling tasks in CakePHP much simpler. 5 | 6 | Author 7 | ------ 8 | Trent Richardson [http://trentrichardson.com] 9 | 10 | License 11 | ------- 12 | Copyright 2015 Trent Richardson 13 | 14 | You may use this project under MIT license. 15 | http://trentrichardson.com/Impromptu/MIT-LICENSE.txt 16 | 17 | How It Works 18 | ------------ 19 | SchedulerShell works by scheduling one cron (SchedulerShell) for your project. Then in bootstrap.php you can create intervals for all your tasks. Deploying new scheduled tasks are now much easier; one crontab entry executes all your tasks. 20 | 21 | Install 22 | ------- 23 | 24 | This Shell was developed for CakePHP 3. 25 | 26 | ### Composer Installation (recommended) 27 | 28 | In your project's composer.json file add this to your require: 29 | 30 | ```` 31 | "trentrichardson/cakephp-scheduler": "~3.0" 32 | ```` 33 | 34 | ### Manual Installation 35 | 36 | Copy the Scheduler plugin into your App/Plugin folder and rename the folder to Scheduler. 37 | 38 | ### Load the Plugin 39 | 40 | In your bootstrap.php file add either: 41 | 42 | ```php 43 | Plugin::loadAll(); 44 | ``` 45 | 46 | or 47 | 48 | ```php 49 | Plugin::load('Scheduler',['autoload'=>true]); 50 | ``` 51 | 52 | Schedule a single system cron by the shortest interval you need for SchedulerShell.php. For example, if you have 5 tasks and the most often run is every 5 minutes, then schedule this cron to run at least every 5 minutes. For more help see [Shells as Cron Jobs](http://book.cakephp.org/2.0/en/console-and-shells/cron-jobs.html). 53 | 54 | Example cron job: 55 | 56 | ```` 57 | */5 * * * * cd /path/to/app && bin/cake Scheduler.Scheduler 58 | ```` 59 | 60 | This would run the SchedulerShell every 5 minutes. 61 | 62 | Now once this shell is scheduled we are able to add our entries to bootstrap.php. Lets say we want to schedule a CleanUp task daily at 5am and a NewsletterTask for every 15 minutes. 63 | 64 | ```php 65 | Configure::write('SchedulerShell.jobs', array( 66 | 'CleanUp' => array('interval' => 'next day 5:00', 'task' => 'CleanUp'),// tomorrow at 5am 67 | 'Newsletters' => array('interval' => 'PT15M', 'task' => 'Newsletter') //every 15 minutes 68 | )); 69 | ``` 70 | 71 | The key to each entry will be used to store the previous run. *These must be unique*! 72 | 73 | **interval** is set one of two ways. 74 | 1) For set times of day we use PHP's [relative time formats](http://www.php.net/manual/en/datetime.formats.relative.php): "next day 5:00". 75 | 76 | 2) To use an interval to achieve "every 15 minutes" we use [DateInterval](http://www.php.net/manual/en/class.dateinterval.php) string format: "PT15M". 77 | 78 | **task** is simply the name of the Task. 79 | 80 | There are a couple optional arguments you may pass: "action" and "pass". 81 | 82 | **action** defaults to "execute", which is the method name to call in the task you specify. 83 | 84 | **pass** defaults to array(), which is the array of arguments to pass to your "action". 85 | 86 | ```php 87 | Configure::write('SchedulerShell.jobs', array( 88 | 'CleanUp' => array('interval' => 'next day 5:00', 'task' => 'CleanUp', 'action' => 'execute', 'pass' => array()), 89 | 'Newsletters' => array('interval' => 'PT15M', 'task' => 'Newsletter', 'action' => 'execute', 'pass' => array()) 90 | )); 91 | ``` 92 | 93 | Storage of Results 94 | ------------------ 95 | SchedulerShell keeps track of each run in a json file. By default this is stored in TMP and is named "cron_scheduler.json". 96 | 97 | If you need to change either of these you may use: 98 | 99 | ```php 100 | // change the file name 101 | Configure::write('SchedulerShell.storeFile', "scheduler_results.json"); 102 | // change the path (note the ending /) 103 | Configure::write('SchedulerShell.storePath', "/path/to/save/"); 104 | ``` 105 | 106 | Preventing Simultaneous SchedulerShells Running Same Tasks 107 | ---------------------------------------------------------- 108 | By default, the SchedulerShell will exit if it is already running and has been for less than 10 minutes. You can adjust this by setting: 109 | 110 | ```php 111 | // change the number of seconds to wait before running a parallel SchedulerShell; 0 = do not exit 112 | Configure::write('SchedulerShell.processTimeout', 5*60); 113 | ``` 114 | 115 | Other Notes/Known Issues 116 | ------------------------ 117 | - The optional pass arguments have not been thoroughly tested 118 | - PHP prior to version 5.3.6 only used relative datetime for the DateTime::modify() function. This could result in an interval of "next day 5:00" not running if the previous `lastRun` time was 05:02. Therefore this plugin should only be run on PHP >= 5.3.6. 119 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trentrichardson/cakephp-scheduler", 3 | "type": "cakephp-plugin", 4 | "description": "Makes scheduling tasks in CakePHP much simpler.", 5 | "keywords": ["cakephp","scheduler","cron job","task"], 6 | "homepage": "http://github.com/trentrichardson/cakephp-scheduler", 7 | "license": "MIT", 8 | "autoload": { 9 | "psr-4": { 10 | "Scheduler\\": "src" 11 | } 12 | }, 13 | "require": { 14 | "php": ">=5.3.6" 15 | }, 16 | "support": { 17 | "source": "https://github.com/trentrichardson/cakephp-scheduler" 18 | }, 19 | "extra": { 20 | "installer-name": "Scheduler" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Shell/SchedulerShell.php: -------------------------------------------------------------------------------- 1 | array('interval'=>'next day 5:00','task'=>'CleanUp'),// tomorrow at 5am 20 | * 'Newsletters' => array('interval'=>'PT15M','task'=>'Newsletter') //every 15 minutes 21 | * )); 22 | * 23 | * ------------------------------------------------------------------- 24 | * Run a shell task: 25 | * - Cd into app dir 26 | * - run this: 27 | * >> bin/cake Scheduler.Scheduler 28 | * 29 | * ------------------------------------------------------------------- 30 | * Troubleshooting 31 | * - may have to run dos2unix to fix line endings in the bin/cake file 32 | * - if you didn't use composer to install this plugin you may need to 33 | * enable the plugin with Plugin::load('Scheduler', [ 'autoload'=>true ]); 34 | */ 35 | namespace Scheduler\Shell; 36 | 37 | use Cake\Console\Shell; 38 | use Cake\Core\Configure; 39 | use Cake\Filesystem\Folder; 40 | use Cake\Filesystem\File; 41 | use \DateTime; 42 | use \DateInterval; 43 | 44 | class SchedulerShell extends Shell{ 45 | 46 | public $tasks = array(); 47 | 48 | /** 49 | * The array of scheduled tasks. 50 | */ 51 | private $schedule = array(); 52 | 53 | /** 54 | * The key which you set Configure::read() for your jobs 55 | */ 56 | private $configKey = 'SchedulerShell'; 57 | 58 | /** 59 | * The path where the store file is placed. null will store in Config folder 60 | */ 61 | private $storePath = null; 62 | 63 | /** 64 | * The file name of the store 65 | */ 66 | private $storeFile = 'cron_scheduler.json'; 67 | 68 | /** 69 | * The number of seconds to wait before running a parallel SchedulerShell 70 | */ 71 | private $processingTimeout = 600; 72 | 73 | /** 74 | * The main method which you want to schedule for the most frequent interval 75 | */ 76 | 77 | /** 78 | * main function. 79 | * 80 | * @access public 81 | * @return void 82 | */ 83 | public function main() { 84 | 85 | // read in the config 86 | if ($config = Configure::read($this->configKey)) { 87 | 88 | if (isset($config['storePath'])) { 89 | $this->storePath = $config['storePath']; 90 | } 91 | 92 | if (isset($config['storeFile'])) { 93 | $this->storeFile = $config['storeFile']; 94 | } 95 | 96 | if (isset($config['processingTimeout'])) { 97 | $this->processingTimeout = $config['processingTimeout']; 98 | } 99 | 100 | // read in the jobs from the config 101 | if (isset($config['jobs'])) { 102 | foreach ($config['jobs'] as $k => $v) { 103 | $v = $v + array('action' => 'main', 'pass' => array()); 104 | $this->connect($k, $v['interval'], $v['task'], $v['action'], $v['pass']); 105 | } 106 | } 107 | } 108 | 109 | // ok, run them when they're ready 110 | $this->runjobs(); 111 | } 112 | 113 | /** 114 | * The connect method adds tasks to the schedule 115 | * 116 | * @access public 117 | * @param string $name - unique name for this job, isn't bound to anything and doesn't matter what it is 118 | * @param string $interval - date interval string "PT5M" (every 5 min) or a relative Date string "next day 10:00" 119 | * @param string $task - name of the cake task to call 120 | * @param string $action - name of the method within the task to call 121 | * @param array $pass - array of arguments to pass to the method 122 | * @return void 123 | */ 124 | public function connect($name, $interval, $task, $action = 'execute', $pass = array()) { 125 | $this->schedule[$name] = array( 126 | 'name' => $name, 127 | 'interval' => $interval, 128 | 'task' => $task, 129 | 'action' => $action, 130 | 'args' => $pass, 131 | 'lastRun' => null, 132 | 'lastResult' => '' 133 | ); 134 | } 135 | 136 | /** 137 | * Process the tasks when they need to run 138 | * 139 | * @access private 140 | * @return void 141 | */ 142 | private function runjobs() { 143 | $dir = new Folder(TMP); 144 | 145 | // set processing flag so function takes place only once at any given time 146 | $processing = count($dir->find('\.scheduler_running_flag')); 147 | $processingFlag = new File($dir->slashTerm($dir->pwd()) . '.scheduler_running_flag'); 148 | 149 | if ($processing && (time() - $processingFlag->lastChange()) < $this->processingTimeout) { 150 | $this->out("Scheduler already running! Exiting."); 151 | return false; 152 | } else { 153 | $processingFlag->delete(); 154 | $processingFlag->create(); 155 | } 156 | 157 | if (!$this->storePath) { 158 | $this->storePath = TMP; 159 | } 160 | 161 | // look for a store of the previous run 162 | $store = ""; 163 | $storeFilePath = $this->storePath.$this->storeFile; 164 | if (file_exists($storeFilePath)) { 165 | $store = file_get_contents($storeFilePath); 166 | } 167 | $this->out('Reading from: '. $storeFilePath); 168 | 169 | // build or rebuild the store 170 | if ($store != '') { 171 | $store = json_decode($store, true); 172 | } else { 173 | $store = $this->schedule; 174 | } 175 | 176 | // run the jobs that need to be run, record the time 177 | foreach ($this->schedule as $name => $job) { 178 | $now = new DateTime(); 179 | $task = $job['task']; 180 | $action = $job['action']; 181 | 182 | // if the job has never been run before, create it 183 | if (!isset($store[$name])) { 184 | $store[$name] = $job; 185 | } 186 | 187 | // figure out the last run date 188 | $tmptime = $store[$name]['lastRun']; 189 | if ($tmptime == null) { 190 | $tmptime = new DateTime("1969-01-01 00:00:00"); 191 | } elseif (is_array($tmptime)) { 192 | $tmptime = new DateTime($tmptime['date'], new DateTimeZone($tmptime['timezone'])); 193 | } elseif (is_string($tmptime)) { 194 | $tmptime = new DateTime($tmptime); 195 | } 196 | 197 | // determine the next run time based on the last 198 | if (substr($job['interval'],0,1) === 'P') { 199 | $tmptime->add(new DateInterval($job['interval'])); // "P10DT4H" http://www.php.net/manual/en/class.dateinterval.php 200 | } else { 201 | $tmptime->modify($job['interval']); // "next day 10:30" http://www.php.net/manual/en/datetime.formats.relative.php 202 | } 203 | 204 | // is it time to run? has it never been run before? 205 | if ($tmptime <= $now) { 206 | $this->hr(); 207 | $this->out("Running $name"); 208 | $this->hr(); 209 | 210 | if (!isset($this->$task)) { 211 | $this->$task = $this->Tasks->load($task); 212 | 213 | // load models if they aren't already 214 | // foreach ($this->$task->uses as $mk => $mv) { 215 | // if (!isset($this->$task->$mv)) { 216 | // App::uses('AppModel', 'Model'); 217 | // App::uses($mv, 'Model'); 218 | // $this->$task->$mv = new $mv(); 219 | // } 220 | // } 221 | } 222 | 223 | // grab the entire schedule record incase it was updated.. 224 | $store[$name] = $this->schedule[$name]; 225 | 226 | // execute the task and store the result 227 | $store[$name]['lastResult'] = call_user_func_array(array($this->$task, $action), $job['args']); 228 | 229 | // assign it the current time 230 | $now = new DateTime(); 231 | $store[$name]['lastRun'] = $now->format('Y-m-d H:i:s'); 232 | } else { 233 | $this->hr(); 234 | $this->out("Not time to run $name, skipping."); 235 | $this->hr(); 236 | } 237 | } 238 | 239 | // write the store back to the file 240 | file_put_contents($this->storePath.$this->storeFile, json_encode($store)); 241 | 242 | // remove processing flag 243 | $processingFlag->delete(); 244 | } 245 | } 246 | --------------------------------------------------------------------------------