├── LICENSE ├── README.md ├── admin ├── pre-commit └── setup.sh ├── composer.json └── src ├── Commands ├── Generators │ ├── MigrationGenerator.php │ └── Views │ │ └── queue_migration.tpl.php └── Queue │ └── Work.php ├── Config ├── Queue.php ├── Services.php └── _Generators.php ├── Exceptions └── QueueException.php ├── Handlers ├── BaseHandler.php ├── DatabaseHandler.php ├── DatabaseHandler │ └── Model.php └── RabbitMQHandler.php ├── Jobs └── Job.php ├── Language └── en │ └── Queue.php ├── Message.php ├── Queue.php ├── QueueInterface.php └── Status.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2019 British Columbia Institute of Technology 4 | Copyright (c) 2019-2021 CodeIgniter Foundation 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeIgniter 4 Tasks 2 | 3 | A queue interface for CodeIgniter 4. 4 | -------------------------------------------------------------------------------- /admin/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | PROJECT=`php -r "echo dirname(dirname(dirname(realpath('$0'))));"` 4 | STAGED_FILES_CMD=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php` 5 | 6 | # Determine if a file list is passed 7 | if [ "$#" -eq 1 ] 8 | then 9 | oIFS=$IFS 10 | IFS=' 11 | ' 12 | SFILES="$1" 13 | IFS=$oIFS 14 | fi 15 | SFILES=${SFILES:-$STAGED_FILES_CMD} 16 | 17 | echo "Checking PHP Lint..." 18 | for FILE in $SFILES 19 | do 20 | php -l -d display_errors=0 "$PROJECT/$FILE" 21 | if [ $? != 0 ] 22 | then 23 | echo "Fix the error before commit." 24 | exit 1 25 | fi 26 | FILES="$FILES $FILE" 27 | done 28 | 29 | if [ "$FILES" != "" ] 30 | then 31 | echo "Running Code Sniffer..." 32 | ./vendor/bin/phpcbf --standard=./vendor/codeigniter4/codeigniter4-standard/CodeIgniter4 --encoding=utf-8 -n -p $FILES 33 | fi 34 | 35 | exit $? -------------------------------------------------------------------------------- /admin/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Install a pre-commit hook that 4 | # automatically runs phpcs to fix styles 5 | cp admin/pre-commit .git/hooks/pre-commit 6 | chmod +x .git/hooks/pre-commit -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colethorsen/codeigniter4-queue", 3 | "type": "library", 4 | "description": "Queue System for CodeIgniter 4", 5 | "keywords": [ 6 | "codeigniter", 7 | "codeigniter4", 8 | "queue" 9 | ], 10 | "homepage": "https://github.com/colethorsen/codeigniter4-tasks", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Cole Thorsen", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "noldorinfo", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=7.2" 24 | }, 25 | "require-dev": { 26 | "codeigniter4/codeigniter4": "dev-develop", 27 | "codeigniter4/codeigniter4-standard": "^1.0", 28 | "phpstan/phpstan": "^0.12", 29 | "phpunit/phpunit": "^8.0 || ^9.0", 30 | "fakerphp/faker": "^1.9", 31 | "mockery/mockery": "^1.0", 32 | "squizlabs/php_codesniffer": "^3.3" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "CodeIgniter\\Queue\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Tests\\Support\\": "tests/_support" 42 | } 43 | }, 44 | "repositories": [ 45 | { 46 | "type": "vcs", 47 | "url": "https://github.com/codeigniter4/CodeIgniter4" 48 | } 49 | ], 50 | "minimum-stability": "dev", 51 | "prefer-stable": true, 52 | "scripts": { 53 | "post-update-cmd": [ 54 | "bash admin/setup.sh" 55 | ], 56 | "analyze": "phpstan analyze", 57 | "style": "phpcbf --standard=./vendor/codeigniter4/codeigniter4-standard/CodeIgniter4 tests/ src/", 58 | "test": "phpunit" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Commands/Generators/MigrationGenerator.php: -------------------------------------------------------------------------------- 1 | 'The migration class name.', 55 | ]; 56 | 57 | /** 58 | * The Command's Options 59 | * 60 | * @var array 61 | */ 62 | protected $options = [ 63 | '--table' => 'Table name to use for the queue. Default: "ci_queue".', 64 | '--dbgroup' => 'Database group to use for database sessions. Default: "default".', 65 | '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', 66 | ]; 67 | 68 | /** 69 | * Actually execute a command. 70 | * 71 | * @param array $params 72 | */ 73 | public function run(array $params) 74 | { 75 | $this->component = 'Migration'; 76 | $this->directory = 'Database\Migrations'; 77 | $this->template = 'queue_migration.tpl.php'; 78 | 79 | $table = $params['table'] ?? CLI::getOption('table') ?? 'ci_queue'; 80 | $params[0] = "_create_{$table}_table"; 81 | 82 | $this->execute($params); 83 | } 84 | 85 | /** 86 | * Prepare options and do the necessary replacements. 87 | * 88 | * @param string $class 89 | * 90 | * @return string 91 | */ 92 | protected function prepare(string $class): string 93 | { 94 | $table = $this->getOption('table'); 95 | $DBGroup = $this->getOption('dbgroup'); 96 | 97 | $data['table'] = is_string($table) ? $table : 'ci_queue'; 98 | $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; 99 | 100 | return $this->parseTemplate($class, [], [], $data); 101 | } 102 | 103 | /** 104 | * Change file basename before saving. 105 | * 106 | * @param string $filename 107 | * 108 | * @return string 109 | */ 110 | protected function basename(string $filename): string 111 | { 112 | return gmdate(config('Migrations')->timestampFormat) . basename($filename); 113 | } 114 | 115 | /** 116 | * overwrites the renderTempalte to find the queue_migration view. 117 | * //TODO: if/when this gets added to the core this would get removed 118 | * once the files are in the right place. 119 | */ 120 | protected function renderTemplate(array $data = []): string 121 | { 122 | /* 123 | $reflector = new \ReflectionClass($this); 124 | $path = $reflector->getFileName(); 125 | 126 | $parts = explode('/', $path); 127 | $last = array_pop($parts); 128 | $path = implode('/', $parts); 129 | echo $path; 130 | 131 | exit; 132 | */ 133 | return view("CodeIgniter\\Queue\\Commands\\Generators\\Views\\{$this->template}", $data, ['debug' => false]); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Commands/Generators/Views/queue_migration.tpl.php: -------------------------------------------------------------------------------- 1 | <@php 2 | 3 | namespace {namespace}; 4 | 5 | use CodeIgniter\Database\Migration; 6 | 7 | class {class} extends Migration 8 | { 9 | protected $DBGroup = ''; 10 | 11 | public function up() 12 | { 13 | $this->forge->addField([ 14 | 'id' => [ 15 | 'type' => 'INTEGER', 16 | 'auto_increment' => true, 17 | ], 18 | 'queue' => [ 19 | 'type' => 'VARCHAR', 20 | 'constraint' => 255, 21 | ], 22 | 'status' => [ 23 | 'type' => 'TINYINT', 24 | 'constraint' => 1, 25 | 'unsigned' => true, 26 | ], 27 | 'weight' => [ 'type' => 'INTEGER' ], 28 | 'attempts' => [ 29 | 'type' => 'INTEGER', 30 | 'unsigned' => true, 31 | ], 32 | 'available_at' => [ 'type' => 'DATETIME' ], 33 | 'data' => [ 'type' => 'TEXT' ], 34 | 'progress_current' => [ 'type' => 'INT' ], 35 | 'progress_total' => [ 'type' => 'INT' ], 36 | 'error' => [ 'type' => 'TEXT' ], 37 | 'created_at' => [ 'type' => 'DATETIME' ], 38 | 'updated_at' => [ 'type' => 'DATETIME' ], 39 | ]); 40 | $this->forge->addKey('id', true); 41 | // $this->forge->addKey(['weight', 'id', 'queue', 'status', 'available_at']); 42 | $this->forge->createTable('', true); 43 | } 44 | 45 | public function down() 46 | { 47 | $this->forge->dropTable('', true); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Commands/Queue/Work.php: -------------------------------------------------------------------------------- 1 | 'The name of the queue to work, if not specified it will work the default queue', 54 | ]; 55 | 56 | /** 57 | * Actually execute a command. 58 | * 59 | * @param array $params 60 | */ 61 | public function run(array $params) 62 | { 63 | $queue = $params['queue'] ?? config('Queue')->defaultQueue; 64 | 65 | CLI::write('Working Queue: ' . $queue, 'yellow'); 66 | 67 | $response = true; 68 | $jobsProcessed = 0; 69 | $startTime = time(); 70 | 71 | do 72 | { 73 | try 74 | { 75 | $this->stopIfNecessary($startTime, $jobsProcessed); 76 | 77 | $response = \Config\Services::queue()->fetch([$this, 'fire'], $queue); 78 | 79 | $jobsProcessed++; 80 | } 81 | catch(QueueException $e) 82 | { 83 | throw $e; 84 | } 85 | catch (\Throwable $e) 86 | { 87 | CLI::error('Failed', 'light_red'); 88 | CLI::error("Exception: {$e->getCode()} - {$e->getMessage()}\nfile: {$e->getFile()}:{$e->getLine()}"); 89 | 90 | log_exception($e); 91 | } 92 | } 93 | while($response === true); 94 | 95 | CLI::write('Completed Working Queue', 'green'); 96 | } 97 | 98 | /** 99 | * work an individual item in the queue. 100 | * 101 | * @param array $data 102 | * 103 | * @return string 104 | */ 105 | public function fire(array $data) 106 | { 107 | if (isset($data['command'])) 108 | { 109 | CLI::write('Executing Command: ' . $data['command']); 110 | 111 | command($data['command']); 112 | } 113 | else if (isset($data['job'])) 114 | { 115 | CLI::write('Executing Job: ' . $data['job']); 116 | 117 | $data['job']::handle($data['data']); 118 | } 119 | else if (isset($data['closure'])) 120 | { 121 | CLI::write('Executing Closure: ' . $data['job']); 122 | 123 | $data['job']::handle($data['data']); 124 | } 125 | else 126 | { 127 | throw QueueException::couldNotWork(); 128 | } 129 | 130 | CLI::write('Success', 'green'); 131 | } 132 | 133 | /** 134 | * Determine if we should stop the worker. 135 | * 136 | * @param integer $startTime 137 | * @param integer $jobsProcessed 138 | */ 139 | protected function stopIfNecessary($startTime, $jobsProcessed) 140 | { 141 | $shouldQuit = false; 142 | 143 | $maxTime = ini_get('max_execution_time') - 5; //max execution time minus a bit of a buffer (5 sec). 144 | 145 | $maxMemory = ($this->getMemoryLimit() / 1024 / 1024) - 10; //max memory with a buffer (10MB); 146 | $memoryUsage = memory_get_usage(true) / 1024 / 1024; 147 | 148 | $maxBatch = config('Queue')->maxWorkerBatch; 149 | 150 | //max time limit. 151 | if ($maxTime > 0 && time() - $startTime > $maxTime) 152 | { 153 | $shouldQuit = true; 154 | $reason = 'Time Limit Reached'; 155 | } 156 | //max memory 157 | else if ($maxMemory > 0 && $memoryUsage > $maxMemory) 158 | { 159 | $shouldQuit = true; 160 | $reason = 'Memory Limit Reached'; 161 | } 162 | else if ($maxBatch > 0 && $jobsProcessed >= $maxBatch) 163 | { 164 | $shouldQuit = true; 165 | $reason = 'Maxmium Batch Size Reached'; 166 | } 167 | 168 | if (isset($reason)) 169 | { 170 | CLI::write('Exiting Worker: ' . $reason, 'yellow'); 171 | exit; 172 | } 173 | 174 | return true; 175 | } 176 | /** 177 | * calculate the memory limit 178 | * 179 | * @return integer memory limit in bytes. 180 | */ 181 | protected function getMemoryLimit() 182 | { 183 | $memory_limit = ini_get('memory_limit'); 184 | 185 | //if there is no memory limit just set it to 2GB 186 | if($memory_limit = -1) 187 | return 2 * 1024 * 1024 * 1024; 188 | 189 | preg_match('/^(\d+)(.)$/', $memory_limit, $matches); 190 | 191 | if(!isset($matches[2])) 192 | throw new \Exception('Unknown Memory Limit'); 193 | 194 | switch($matches[2]) 195 | { 196 | case 'G' : 197 | $memoryLimit = $matches[1] * 1024 * 1024 * 1024; 198 | break; 199 | case 'M' : 200 | $memoryLimit = $matches[1] * 1024 * 1024; 201 | break; 202 | case 'K' : 203 | $memoryLimit = $matches[1] * 1024; 204 | break; 205 | default : 206 | throw new \Exception('Unknown Memory Limit'); 207 | 208 | return $memoryLimit; 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Config/Queue.php: -------------------------------------------------------------------------------- 1 | 'RabbitMQ', 23 | 'host' => 'localhost', 24 | 'port' => 5672, 25 | 'user' => 'guest', 26 | 'password' => 'guest', 27 | 'vhost' => '/', 28 | 'do_setup' => true, 29 | ]; 30 | */ 31 | public $database = [ 32 | 'handler' => 'Database', 33 | 'dbGroup' => 'default', 34 | 'sharedConnection' => true, 35 | 'table' => 'ci_queue', 36 | ]; 37 | 38 | /* 39 | public $tests = [ 40 | 'handler' => 'Database', 41 | 'dbGroup' => 'tests', 42 | 'sharedConnection' => true, 43 | 'table' => 'ci_queue', 44 | ]; 45 | */ 46 | } 47 | -------------------------------------------------------------------------------- /src/Config/Services.php: -------------------------------------------------------------------------------- 1 | connect(); 31 | } 32 | 33 | //-------------------------------------------------------------------- 34 | } 35 | -------------------------------------------------------------------------------- /src/Config/_Generators.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public $views = [ 29 | 'make:queue' => 'CodeIgniter\Queue\Commands\Generators\Views\queue_migration.tpl.php', 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/QueueException.php: -------------------------------------------------------------------------------- 1 | defaultQueue = $config->defaultQueue; 39 | 40 | $this->available_at = new Time; 41 | } 42 | 43 | /** 44 | * send message to queueing system. 45 | * 46 | * @param array $data 47 | * @param string $queue 48 | * 49 | * @return Message the message that was just created. 50 | */ 51 | abstract public function send($data, string $queue = ''): Message; 52 | 53 | /** 54 | * Fetch message from queueing system. 55 | * When there are no message, this method will return (won't wait). 56 | * 57 | * @param callable $callback 58 | * @param string $queue 59 | * @return boolean whether callback is done or not. 60 | */ 61 | abstract public function fetch(callable $callback, string $queue = ''): bool; 62 | 63 | /** 64 | * Receive message from queueing system. 65 | * When there are no message, this method will wait. 66 | * 67 | * @param callable $callback 68 | * @param string $queue 69 | * @return boolean whether callback is done or not. 70 | */ 71 | abstract public function receive(callable $callback, string $queue = ''): bool; 72 | 73 | /** 74 | * Track progress of a message in the queuing system. 75 | * 76 | * @param int $currentStep the current step number 77 | * @param int $totalSteps the total number of steps 78 | */ 79 | abstract public function progress(int $currentStep, int $totalSteps); 80 | 81 | /** 82 | * Get info on a message in the queuing system. 83 | * 84 | * @param $id identifier in the queue. 85 | */ 86 | abstract public function getMessage(string $id); 87 | 88 | /** 89 | * Set the delay in minutes 90 | * 91 | * @param integer $min 92 | * @return $this 93 | */ 94 | public function delay($min) 95 | { 96 | $this->available_at = (new Time)->modify('+' . $min . ' minutes'); 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * Set the delay to a specific time 103 | * 104 | * @param datetime $datetime 105 | * @param mixed $time 106 | * @return $this 107 | */ 108 | public function delayUntil($time) 109 | { 110 | if ( ! $time instanceof Time) 111 | { 112 | if ($time instanceof \DateTime) 113 | { 114 | $time = Time::instance($time, 'en_US'); 115 | } 116 | else 117 | { 118 | $time = new Time($time); 119 | } 120 | } 121 | 122 | $this->available_at = $time; 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * Set the weight 129 | * 130 | * @param integer $weight 131 | * @return $this 132 | */ 133 | public function weight($weight) 134 | { 135 | $this->weight = $weight; 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * run a command from the queue 142 | * 143 | * @param string $command the command to run 144 | */ 145 | public function command(string $command): Message 146 | { 147 | $data = [ 148 | 'command' => $command, 149 | ]; 150 | 151 | return $this->send($data); 152 | } 153 | 154 | /** 155 | * run an anonymous function from the queue. 156 | * 157 | * @param callable $closure function to run 158 | * 159 | * TODO: this currently doesn't work with database 160 | * as you can't serialize a closure. May need 161 | * to implement something like laravel does to get 162 | * around this. 163 | */ 164 | public function closure(callable $closure): Message 165 | { 166 | $data = [ 167 | 'closure' => $closure, 168 | ]; 169 | 170 | return $this->send($data); 171 | } 172 | 173 | /** 174 | * run a job from the queue 175 | * 176 | * @param string $job the job to run 177 | * @param mixed $data data for the job 178 | */ 179 | public function job(string $job, $data = []): Message 180 | { 181 | $data = [ 182 | 'job' => $job, 183 | 'data' => $data, 184 | ]; 185 | 186 | return $this->send($data); 187 | } 188 | 189 | /** 190 | * run a job from the queue 191 | * 192 | * @param string $job the job to run 193 | * @param Message $message the message experiencing the error 194 | */ 195 | protected function fireOnFailure(\Throwable $e, Message $message) 196 | { 197 | \CodeIgniter\Events\Events::trigger('queue_failure', $e, $message); 198 | } 199 | 200 | /** 201 | * run a job from the queue 202 | * 203 | * @param Message $message the message that just succeeded. 204 | */ 205 | protected function fireOnSuccess(Message $message) 206 | { 207 | \CodeIgniter\Events\Events::trigger('queue_success', $message); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Handlers/DatabaseHandler.php: -------------------------------------------------------------------------------- 1 | defaultGroup, $connectionConfig['sharedConnection'] ?? true); 53 | 54 | $this->model = new Model($dbConnection); 55 | 56 | $this->model->setTable($connectionConfig['table']); 57 | 58 | $this->timeout = $config->timeout; 59 | $this->maxRetries = $config->maxRetries; 60 | $this->deleteDoneMessagesAfter = $config->deleteDoneMessagesAfter; 61 | } 62 | 63 | /** 64 | * send message to queueing system. 65 | * 66 | * @param array $data 67 | * @param string $queue 68 | * 69 | * @return Message the message that was just created. 70 | */ 71 | public function send($data, string $queue = ''): Message 72 | { 73 | if ($queue === '') 74 | { 75 | $queue = $this->defaultQueue; 76 | } 77 | 78 | $message = (new Message)->fill([ 79 | 'data' => $data, 80 | 'queue' => $queue, 81 | 'available_at' => $this->available_at, 82 | 'weight' => $this->weight, 83 | ]); 84 | 85 | // $this->db->transStart(); 86 | 87 | //check for duplicates. 88 | //There's no reason to create 2 jobs that run the same thing at the 89 | //exact same time on the same queue. 90 | $existing = $this->model->where([ 91 | 'queue' => $message->queue, 92 | 'status' => $message->status, 93 | 'data' => serialize($message->data), 94 | 'available_at' => $message->available_at, 95 | ]) 96 | ->first(); 97 | 98 | if ($existing) 99 | { 100 | return $existing; 101 | } 102 | 103 | $this->model->insert($message); 104 | 105 | $message->id = $this->model->getInsertID(); 106 | $message->created_at = new Time; 107 | $message->updated_at = new Time; 108 | 109 | // $this->db->transComplete(); 110 | 111 | return $message; 112 | } 113 | 114 | /** 115 | * Fetch message from queueing system. 116 | * 117 | * @param callable $callback 118 | * @param string $queue 119 | * @return boolean whether callback is done or not. 120 | */ 121 | public function fetch(callable $callback, string $queue = ''): bool 122 | { 123 | try 124 | { 125 | $message = $this->model 126 | ->where('queue', $queue !== '' ? $queue : $this->defaultQueue) 127 | ->where('status', Status::WAITING) 128 | ->where('available_at <', date('Y-m-d H:i:s')) 129 | ->orderBy('weight') 130 | ->orderBy('id') 131 | ->first(); 132 | } 133 | catch(\Throwable $e) 134 | { 135 | throw QueueException::forFailGetQueueDatabase($this->model->builder()->getTable()); 136 | } 137 | 138 | //if there is nothing else to run at the moment return false. 139 | if ( ! $message) 140 | { 141 | $this->housekeeping(); 142 | 143 | return false; 144 | } 145 | 146 | $message->status = Status::EXECUTING; 147 | 148 | //set the status to executing if it hasn't already been taken. 149 | $this->model 150 | ->where('status', Status::WAITING) 151 | ->save($message); 152 | 153 | //don't run again if its already been taken. 154 | if ($this->model->db->affectedRows() === 0) 155 | { 156 | return true; 157 | } 158 | 159 | //if the callback doesn't throw an exception mark it as done. 160 | try 161 | { 162 | $this->messageID = $message->id; 163 | 164 | $callback($message->data); 165 | 166 | $message->status = Status::DONE; 167 | $message->updated_at = new Time; 168 | 169 | $this->model->save($message); 170 | 171 | $this->fireOnSuccess($message); 172 | } 173 | catch (\Throwable $e) 174 | { 175 | //track any exceptions into the database for easier troubleshooting. 176 | $error = "{$e->getCode()} - {$e->getMessage()}\n\n" . 177 | "file: {$e->getFile()}:{$e->getLine()}\n" . 178 | "------------------------------------------------------\n\n"; 179 | 180 | $message->error = $message->error . $error; 181 | $message->updated_at = new Time; 182 | 183 | $this->model->save($message); 184 | 185 | $this->fireOnFailure($e, $message); 186 | 187 | throw $e; 188 | } 189 | 190 | //there could be more to run so return true. 191 | return true; 192 | } 193 | 194 | /** 195 | * Receive message from queueing system. 196 | * When there are no message, this method will wait. 197 | * 198 | * @param callable $callback 199 | * @param string $queue 200 | * @return boolean whether callback is done or not. 201 | */ 202 | public function receive(callable $callback, string $queue = ''): bool 203 | { 204 | while ( ! $this->fetch($callback, $queue)) 205 | { 206 | usleep(1000000); 207 | } 208 | 209 | return true; 210 | } 211 | 212 | /** 213 | * Track progress of a message in the queuing system. 214 | * 215 | * @param int $currentStep the current step number 216 | * @param int $totalSteps the total number of steps 217 | */ 218 | public function progress(int $currentStep, int $totalSteps) 219 | { 220 | $this->model->update($this->messageID, [ 221 | 'progress_current' => $currentStep, 222 | 'progress_total' => $totalSteps, 223 | 'updated_at' => date('Y-m-d H:i:s'), 224 | ]); 225 | } 226 | 227 | /** 228 | * Get info on a message in the queuing system. 229 | * 230 | * @param $id identifier in the queue. 231 | */ 232 | public function getMessage(string $id) 233 | { 234 | return $this->model->find($id); 235 | } 236 | 237 | /** 238 | * housekeeping. 239 | * 240 | * clean up the database at the end of each run. 241 | */ 242 | public function housekeeping() 243 | { 244 | //update executing statuses to waiting on timeout before max retry. 245 | $this->model 246 | ->set('attempts', 'attempts + 1', false) 247 | ->set('status', Status::WAITING) 248 | ->set('updated_at', date('Y-m-d H:i:s')) 249 | ->where('status', Status::EXECUTING) 250 | ->where('updated_at <', date('Y-m-d H:i:s', time() - $this->timeout)) 251 | ->where('attempts <', $this->maxRetries) 252 | ->update(); 253 | 254 | //update executing statuses to failed on timeout at max retry. 255 | $this->model 256 | ->set('attempts', 'attempts + 1', false) 257 | ->set('status', Status::FAILED) 258 | ->set('updated_at', date('Y-m-d H:i:s')) 259 | ->where('status', Status::EXECUTING) 260 | ->where('updated_at <', date('Y-m-d H:i:s', time() - $this->timeout)) 261 | ->where('attempts >=', $this->maxRetries) 262 | ->update(); 263 | 264 | //Delete messages after the configured period. 265 | if ($this->deleteDoneMessagesAfter !== false) 266 | { 267 | $this->model 268 | ->where('status', Status::DONE) 269 | ->where('updated_at <', date('Y-m-d H:i:s', time() - $this->deleteDoneMessagesAfter)) 270 | ->delete(); 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Handlers/DatabaseHandler/Model.php: -------------------------------------------------------------------------------- 1 | connection = new AMQPStreamConnection( 26 | $groupConfig['host'], 27 | $groupConfig['port'], 28 | $groupConfig['user'], 29 | $groupConfig['password'], 30 | $groupConfig['vhost'] 31 | ); 32 | $this->channel = $this->connection->channel(); 33 | if ($groupConfig['do_setup']) 34 | { 35 | $this->setup(); 36 | } 37 | } 38 | 39 | /** 40 | * Setup queueing system (system-wide). 41 | */ 42 | public function setup() 43 | { 44 | foreach ($this->exchangeMap as $exchangeName => $queues) 45 | { 46 | $this->channel->exchange_declare($exchangeName, 'topic', false, true, false); 47 | foreach ($queues as $routingKey => $queueName) 48 | { 49 | $this->channel->queue_declare($queueName, false, true, false, false); 50 | $this->channel->queue_bind($queueName, $exchangeName, $routingKey); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * close the connection. 57 | * 58 | * AMQPConnection::__destruct() do close the connection, so we haven't to close it on destructor. 59 | */ 60 | public function closeConnection() 61 | { 62 | $this->channel->close(); 63 | $this->connection->close(); 64 | } 65 | 66 | /** 67 | * send message to queueing system. 68 | * 69 | * @param array $data 70 | * @param string $routingKey 71 | * @param string $exchangeName 72 | */ 73 | public function send($data, string $routingKey = '', string $exchangeName = '') 74 | { 75 | $this->channel->basic_publish( 76 | new AMQPMessage(json_encode($data), ['delivery_mode' => 2]), 77 | $exchangeName !== '' ? $exchangeName : $this->defaultExchange, 78 | $routingKey 79 | ); 80 | } 81 | 82 | /** 83 | * Fetch message from queueing system. 84 | * When there are no message, this method will return (won't wait). 85 | * 86 | * @param callable $callback 87 | * @param string $queueName 88 | * @return boolean whether callback is done or not. 89 | */ 90 | public function fetch(callable $callback, string $queueName = '') : bool 91 | { 92 | return $this->consume($callback, $queueName, 0.001); // timeout 0.001sec: dummy for non-waiting 93 | } 94 | 95 | /** 96 | * Receive message from queueing system. 97 | * When there are no message, this method will wait. 98 | * 99 | * @param callable $callback 100 | * @param string $queueName 101 | * @return boolean whether callback is done or not. 102 | */ 103 | public function receive(callable $callback, string $queueName = '') : bool 104 | { 105 | return $this->consume($callback, $queueName); 106 | } 107 | 108 | protected function consume(callable $callback, string $queueName = '', $timeout = 0) 109 | { 110 | $this->channel->basic_qos(null, 1, null); 111 | $this->channel->basic_consume( 112 | $queueName !== '' ? $queueName : $this->defaultQueue, 113 | '', 114 | false, 115 | false, 116 | false, 117 | false, 118 | function ($msg) use ($callback) { 119 | $callback(json_decode($msg->body)); 120 | $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); 121 | } 122 | ); 123 | 124 | $ret = false; 125 | $consumer_tag = null; 126 | try 127 | { 128 | $consumer_tag = $this->channel->wait(null, false, $timeout); 129 | $ret = true; 130 | } 131 | catch (AMQPTimeoutException $ex) 132 | { 133 | // do nothing. 134 | } 135 | 136 | if ($consumer_tag !== null) 137 | { 138 | $this->channel->basic_cancel($consumer_tag); 139 | } 140 | 141 | return $ret; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Jobs/Job.php: -------------------------------------------------------------------------------- 1 | weight(self::$weight ?: self::$defaultWeight); 31 | 32 | self::$weight = null; 33 | 34 | return $queue->job(get_called_class(), $data); 35 | } 36 | 37 | /** 38 | * set a queue other than the default to 39 | * dispatch this job to. 40 | * 41 | * @param string $queue the name of the queue 42 | * @return this 43 | */ 44 | public static function queue($queue) 45 | { 46 | return get_called_class(); 47 | } 48 | 49 | /** 50 | * set a queue other than the default to 51 | * dispatch this job to. 52 | * 53 | * @param int $queue weight other than default 54 | * @return this 55 | */ 56 | public static function weight(int $weight) 57 | { 58 | self::$weight = $weight; 59 | 60 | return get_called_class(); 61 | } 62 | 63 | /** 64 | * delay execution of job until a specific time 65 | * 66 | * @param mixed $time time as a string, time or datetime 67 | * @return this 68 | */ 69 | public static function delayUntil($time) 70 | { 71 | $queue = self::getQueue(); 72 | $queue->delayUntil($time); 73 | 74 | return get_called_class(); 75 | } 76 | 77 | /** 78 | * delay execution of job for a certain number of 79 | * minutes 80 | * 81 | * @param number $min minutes to delay excution 82 | * @return this 83 | */ 84 | public static function delay($min) 85 | { 86 | $queue = self::getQueue(); 87 | $queue->delay($min); 88 | 89 | return get_called_class(); 90 | } 91 | 92 | protected static function getQueue() 93 | { 94 | if ( ! self::$queue) 95 | { 96 | self::$queue = \Config\Services::queue(); 97 | } 98 | 99 | return self::$queue; 100 | } 101 | 102 | /** 103 | * Track the progress of a job. 104 | * 105 | * @param int $currentStep the current step number 106 | * @param int $totalSteps the total number of steps 107 | */ 108 | public static function setProgress(int $currentStep, int $totalSteps) 109 | { 110 | $queue = self::getQueue(); 111 | $queue->progress($currentStep, $totalSteps); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Language/en/Queue.php: -------------------------------------------------------------------------------- 1 | "'{0}' is not a valid queue connection group.", 17 | 'failGetQueueDatabase' => 'There was an error fetching from the queue table: `{0}`', 18 | 19 | 'could_not_work' => 'There is currently no functionality to work this queue, entries should be jobs, commands, or closures', 20 | 21 | 'status' => [ 22 | 'waiting' => 'Waiting', 23 | 'executing' => 'Executing', 24 | 'done' => 'Done', 25 | 'failed' => 'Failed', 26 | 'unknown' => 'Unknown', 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | null, 10 | 'queue' => null, 11 | 'status' => Status::WAITING, 12 | 'attempts' => 0, 13 | 'data' => null, 14 | 'error' => '', 15 | 'progress_current' => 0, 16 | 'progress_total' => 0, 17 | 'weight' => 100, 18 | 'available_at' => null, 19 | 'created_at' => null, 20 | 'updated_at' => null, 21 | ]; 22 | 23 | protected $casts = [ 24 | 'id' => 'integer', 25 | 'attempts' => 'integer', 26 | 'data' => 'array', 27 | 'progress_current' => 'integer', 28 | 'progress_total' => 'integer', 29 | 'status' => 'integer', 30 | 'weight' => 'integer', 31 | 'available_at' => 'datetime', 32 | 'created_at' => 'datetime', 33 | 'updated_at' => 'datetime', 34 | ]; 35 | 36 | /** 37 | * A localized human readable status. 38 | */ 39 | public function getStatusText(): string 40 | { 41 | $class = new \ReflectionClass(Status::class); 42 | $statuses = $class->getConstants(); 43 | 44 | $statuses = array_flip($statuses); 45 | 46 | $status = $statuses[$this->status] ?? false; 47 | 48 | if ($status) 49 | { 50 | return lang('queue.status.' . strtolower($status)); 51 | } 52 | 53 | return lang('queue.status.unknown'); 54 | } 55 | 56 | /** 57 | * a readable progress report on the message. 58 | */ 59 | public function getProgress(): string 60 | { 61 | if ($this->status != Status::EXECUTING) 62 | { 63 | return $this->status_text; 64 | } 65 | 66 | if ($this->progress_total == 0) 67 | { 68 | return $this->status_text; 69 | } 70 | 71 | return ($this->progress_current / $this->progress_total * 100) . '%'; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Queue.php: -------------------------------------------------------------------------------- 1 | defaultConnection; 43 | } 44 | 45 | if (isset($config->$connection)) 46 | { 47 | $connectionConfig = $config->$connection; 48 | } 49 | else 50 | { 51 | throw QueueException::forInvalidconnection($connection); 52 | } 53 | } 54 | 55 | $this->connectionConfig = $connectionConfig; 56 | $this->config = $config; 57 | } 58 | 59 | /** 60 | * connecting queueing system. 61 | * 62 | * @return CodeIgniter\Queue\Handlers\BaseHandler 63 | */ 64 | public function connect() 65 | { 66 | $handler = '\\CodeIgniter\\Queue\\Handlers\\' . $this->connectionConfig['handler'] . 'Handler'; 67 | return new $handler($this->connectionConfig, $this->config); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/QueueInterface.php: -------------------------------------------------------------------------------- 1 |