├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Queue.php ├── common.php ├── config │ └── queue.php ├── facade │ └── Queue.php └── queue │ ├── CallQueuedHandler.php │ ├── Connector.php │ ├── FailedJob.php │ ├── InteractsWithTime.php │ ├── Job.php │ ├── Listener.php │ ├── Queueable.php │ ├── Service.php │ ├── ShouldQueue.php │ ├── Worker.php │ ├── command │ ├── FailedTable.php │ ├── FlushFailed.php │ ├── ForgetFailed.php │ ├── ListFailed.php │ ├── Listen.php │ ├── Restart.php │ ├── Retry.php │ ├── Table.php │ ├── Work.php │ └── stubs │ │ ├── failed_jobs.stub │ │ └── jobs.stub │ ├── connector │ ├── Database.php │ ├── Redis.php │ └── Sync.php │ ├── event │ ├── JobExceptionOccurred.php │ ├── JobFailed.php │ ├── JobProcessed.php │ ├── JobProcessing.php │ └── WorkerStopping.php │ ├── exception │ └── MaxAttemptsExceededException.php │ ├── failed │ ├── Database.php │ └── None.php │ └── job │ ├── Database.php │ ├── Redis.php │ └── Sync.php └── tests ├── DatabaseConnectorTest.php ├── ListenerTest.php ├── QueueTest.php ├── TestCase.php ├── WorkerTest.php └── bootstrap.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | /composer.lock 4 | /thinkphp/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # think-queue for ThinkPHP6 2 | 3 | ## 安装 4 | 5 | > composer require topthink/think-queue 6 | 7 | ## 配置 8 | 9 | > 配置文件位于 `config/queue.php` 10 | 11 | ### 公共配置 12 | 13 | ```bash 14 | [ 15 | 'default'=>'sync' //驱动类型,可选择 sync(默认):同步执行,database:数据库驱动,redis:Redis驱动//或其他自定义的完整的类名 16 | ] 17 | ``` 18 | 19 | 20 | ## 创建任务类 21 | > 推荐使用 `app\job` 作为任务类的命名空间 22 | > 也可以放在任意可以自动加载到的地方 23 | 24 | 任务类不需继承任何类,如果这个类只有一个任务,那么就只需要提供一个`fire`方法就可以了,如果有多个小任务,就写多个方法,下面发布任务的时候会有区别 25 | 每个方法会传入两个参数 `think\queue\Job $job`(当前的任务对象) 和 `$data`(发布任务时自定义的数据) 26 | 27 | 还有个可选的任务失败执行的方法 `failed` 传入的参数为`$data`(发布任务时自定义的数据) 28 | 29 | ### 下面写两个例子 30 | 31 | ```php 32 | namespace app\job; 33 | 34 | use think\queue\Job; 35 | 36 | class Job1{ 37 | 38 | public function fire(Job $job, $data){ 39 | 40 | //....这里执行具体的任务 41 | 42 | if ($job->attempts() > 3) { 43 | //通过这个方法可以检查这个任务已经重试了几次了 44 | } 45 | 46 | 47 | //如果任务执行成功后 记得删除任务,不然这个任务会重复执行,直到达到最大重试次数后失败后,执行failed方法 48 | $job->delete(); 49 | 50 | // 也可以重新发布这个任务 51 | $job->release($delay); //$delay为延迟时间 52 | 53 | } 54 | 55 | public function failed($data){ 56 | 57 | // ...任务达到最大重试次数后,失败了 58 | } 59 | 60 | } 61 | 62 | ``` 63 | 64 | ```php 65 | 66 | namespace app\lib\job; 67 | 68 | use think\queue\Job; 69 | 70 | class Job2{ 71 | 72 | public function task1(Job $job, $data){ 73 | 74 | 75 | } 76 | 77 | public function task2(Job $job, $data){ 78 | 79 | 80 | } 81 | 82 | public function failed($data){ 83 | 84 | 85 | } 86 | 87 | } 88 | 89 | ``` 90 | 91 | 92 | ## 发布任务 93 | > `think\facade\Queue::push($job, $data = '', $queue = null)` 和 `think\facade\Queue::later($delay, $job, $data = '', $queue = null)` 两个方法,前者是立即执行,后者是在`$delay`秒后执行 94 | 95 | `$job` 是任务名 96 | 命名空间是`app\job`的,比如上面的例子一,写`Job1`类名即可 97 | 其他的需要些完整的类名,比如上面的例子二,需要写完整的类名`app\lib\job\Job2` 98 | 如果一个任务类里有多个小任务的话,如上面的例子二,需要用@+方法名`app\lib\job\Job2@task1`、`app\lib\job\Job2@task2` 99 | 100 | `$data` 是你要传到任务里的参数 101 | 102 | `$queue` 队列名,指定这个任务是在哪个队列上执行,同下面监控队列的时候指定的队列名,可不填 103 | 104 | ## 监听任务并执行 105 | 106 | ```bash 107 | &> php think queue:listen 108 | 109 | &> php think queue:work 110 | ``` 111 | 112 | 两种,具体的可选参数可以输入命令加 `--help` 查看 113 | 114 | > 可配合supervisor使用,保证进程常驻 115 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "topthink/think-queue", 3 | "description": "The ThinkPHP6 Queue Package", 4 | "authors": [ 5 | { 6 | "name": "yunwuxin", 7 | "email": "448901948@qq.com" 8 | } 9 | ], 10 | "license": "Apache-2.0", 11 | "autoload": { 12 | "psr-4": { 13 | "think\\": "src" 14 | }, 15 | "files": [ 16 | "src/common.php" 17 | ] 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "think\\test\\queue\\": "tests" 22 | } 23 | }, 24 | "require": { 25 | "ext-json": "*", 26 | "topthink/framework": "^6.0 || ^8.0", 27 | "symfony/process": ">=4.2", 28 | "nesbot/carbon": ">=2.16" 29 | }, 30 | "extra": { 31 | "think": { 32 | "services": [ 33 | "think\\queue\\Service" 34 | ], 35 | "config": { 36 | "queue": "src/config/queue.php" 37 | } 38 | } 39 | }, 40 | "require-dev": { 41 | "phpunit/phpunit": "^6.2", 42 | "mockery/mockery": "^1.2", 43 | "topthink/think-migration": "^3.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | ./src/queue/Service.php 25 | ./src/common.php 26 | ./src/config.php 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Queue.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think; 13 | 14 | use think\queue\Connector; 15 | use think\queue\connector\Database; 16 | use think\queue\connector\Redis; 17 | 18 | /** 19 | * Class Queue 20 | * @package think\queue 21 | * 22 | * @mixin Database 23 | * @mixin Redis 24 | */ 25 | class Queue extends Manager 26 | { 27 | protected $namespace = '\\think\\queue\\connector\\'; 28 | 29 | protected function resolveType(string $name) 30 | { 31 | return $this->app->config->get("queue.connections.{$name}.type", 'sync'); 32 | } 33 | 34 | protected function resolveConfig(string $name) 35 | { 36 | return $this->app->config->get("queue.connections.{$name}"); 37 | } 38 | 39 | protected function createDriver(string $name) 40 | { 41 | /** @var Connector $driver */ 42 | $driver = parent::createDriver($name); 43 | 44 | return $driver->setApp($this->app) 45 | ->setConnection($name); 46 | } 47 | 48 | /** 49 | * @param null|string $name 50 | * @return Connector 51 | */ 52 | public function connection($name = null) 53 | { 54 | return $this->driver($name); 55 | } 56 | 57 | /** 58 | * 默认驱动 59 | * @return string 60 | */ 61 | public function getDefaultDriver() 62 | { 63 | return $this->app->config->get('queue.default'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/common.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | use think\facade\Queue; 13 | 14 | if (!function_exists('queue')) { 15 | 16 | /** 17 | * 添加到队列 18 | * @param $job 19 | * @param string $data 20 | * @param int $delay 21 | * @param null $queue 22 | */ 23 | function queue($job, $data = '', $delay = 0, $queue = null) 24 | { 25 | if ($delay > 0) { 26 | Queue::later($delay, $job, $data, $queue); 27 | } else { 28 | Queue::push($job, $data, $queue); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config/queue.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | return [ 13 | 'default' => 'sync', 14 | 'connections' => [ 15 | 'sync' => [ 16 | 'type' => 'sync', 17 | ], 18 | 'database' => [ 19 | 'type' => 'database', 20 | 'queue' => 'default', 21 | 'table' => 'jobs', 22 | 'connection' => null, 23 | ], 24 | 'redis' => [ 25 | 'type' => 'redis', 26 | 'queue' => 'default', 27 | 'host' => '127.0.0.1', 28 | 'port' => 6379, 29 | 'password' => '', 30 | 'select' => 0, 31 | 'timeout' => 0, 32 | 'persistent' => false, 33 | ], 34 | ], 35 | 'failed' => [ 36 | 'type' => 'none', 37 | 'table' => 'failed_jobs', 38 | ], 39 | ]; 40 | -------------------------------------------------------------------------------- /src/facade/Queue.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | use think\App; 15 | 16 | class CallQueuedHandler 17 | { 18 | protected $app; 19 | 20 | public function __construct(App $app) 21 | { 22 | $this->app = $app; 23 | } 24 | 25 | public function call(Job $job, array $data) 26 | { 27 | $command = unserialize($data['command']); 28 | 29 | $this->app->invoke([$command, 'handle'], [$job]); 30 | 31 | if (!$job->isDeletedOrReleased()) { 32 | $job->delete(); 33 | } 34 | } 35 | 36 | public function failed(array $data) 37 | { 38 | $command = unserialize($data['command']); 39 | 40 | if (method_exists($command, 'failed')) { 41 | $command->failed(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/queue/Connector.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | use DateTimeInterface; 15 | use InvalidArgumentException; 16 | use think\App; 17 | 18 | abstract class Connector 19 | { 20 | /** @var App */ 21 | protected $app; 22 | 23 | /** 24 | * The connector name for the queue. 25 | * 26 | * @var string 27 | */ 28 | protected $connection; 29 | 30 | protected $options = []; 31 | 32 | abstract public function size($queue = null); 33 | 34 | abstract public function push($job, $data = '', $queue = null); 35 | 36 | public function pushOn($queue, $job, $data = '') 37 | { 38 | return $this->push($job, $data, $queue); 39 | } 40 | 41 | abstract public function pushRaw($payload, $queue = null, array $options = []); 42 | 43 | abstract public function later($delay, $job, $data = '', $queue = null); 44 | 45 | public function laterOn($queue, $delay, $job, $data = '') 46 | { 47 | return $this->later($delay, $job, $data, $queue); 48 | } 49 | 50 | public function bulk($jobs, $data = '', $queue = null) 51 | { 52 | foreach ((array) $jobs as $job) { 53 | $this->push($job, $data, $queue); 54 | } 55 | } 56 | 57 | abstract public function pop($queue = null); 58 | 59 | protected function createPayload($job, $data = '') 60 | { 61 | $payload = $this->createPayloadArray($job, $data); 62 | 63 | $payload = json_encode($payload); 64 | 65 | if (JSON_ERROR_NONE !== json_last_error()) { 66 | throw new InvalidArgumentException('Unable to create payload: ' . json_last_error_msg()); 67 | } 68 | 69 | return $payload; 70 | } 71 | 72 | protected function createPayloadArray($job, $data = '') 73 | { 74 | return is_object($job) 75 | ? $this->createObjectPayload($job) 76 | : $this->createPlainPayload($job, $data); 77 | } 78 | 79 | protected function createPlainPayload($job, $data) 80 | { 81 | return [ 82 | 'job' => $job, 83 | 'maxTries' => null, 84 | 'timeout' => null, 85 | 'data' => $data, 86 | ]; 87 | } 88 | 89 | protected function createObjectPayload($job) 90 | { 91 | return [ 92 | 'job' => 'think\queue\CallQueuedHandler@call', 93 | 'maxTries' => $job->tries ?? null, 94 | 'timeout' => $job->timeout ?? null, 95 | 'timeoutAt' => $this->getJobExpiration($job), 96 | 'data' => [ 97 | 'commandName' => get_class($job), 98 | 'command' => serialize(clone $job), 99 | ], 100 | ]; 101 | } 102 | 103 | public function getJobExpiration($job) 104 | { 105 | if (!method_exists($job, 'retryUntil') && !isset($job->timeoutAt)) { 106 | return; 107 | } 108 | 109 | $expiration = $job->timeoutAt ?? $job->retryUntil(); 110 | 111 | return $expiration instanceof DateTimeInterface 112 | ? $expiration->getTimestamp() : $expiration; 113 | } 114 | 115 | protected function setMeta($payload, $key, $value) 116 | { 117 | $payload = json_decode($payload, true); 118 | $payload[$key] = $value; 119 | $payload = json_encode($payload); 120 | 121 | if (JSON_ERROR_NONE !== json_last_error()) { 122 | throw new InvalidArgumentException('Unable to create payload: ' . json_last_error_msg()); 123 | } 124 | 125 | return $payload; 126 | } 127 | 128 | public function setApp(App $app) 129 | { 130 | $this->app = $app; 131 | return $this; 132 | } 133 | 134 | /** 135 | * Get the connector name for the queue. 136 | * 137 | * @return string 138 | */ 139 | public function getConnection() 140 | { 141 | return $this->connection; 142 | } 143 | 144 | /** 145 | * Set the connector name for the queue. 146 | * 147 | * @param string $name 148 | * @return $this 149 | */ 150 | public function setConnection($name) 151 | { 152 | $this->connection = $name; 153 | 154 | return $this; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/queue/FailedJob.php: -------------------------------------------------------------------------------- 1 | parseDateInterval($delay); 20 | 21 | return $delay instanceof DateTimeInterface 22 | ? max(0, $delay->getTimestamp() - $this->currentTime()) 23 | : (int) $delay; 24 | } 25 | 26 | /** 27 | * Get the "available at" UNIX timestamp. 28 | * 29 | * @param DateTimeInterface|DateInterval|int $delay 30 | * @return int 31 | */ 32 | protected function availableAt($delay = 0) 33 | { 34 | $delay = $this->parseDateInterval($delay); 35 | 36 | return $delay instanceof DateTimeInterface 37 | ? $delay->getTimestamp() 38 | : Carbon::now()->addRealSeconds($delay)->getTimestamp(); 39 | } 40 | 41 | /** 42 | * If the given value is an interval, convert it to a DateTime instance. 43 | * 44 | * @param DateTimeInterface|DateInterval|int $delay 45 | * @return DateTimeInterface|int 46 | */ 47 | protected function parseDateInterval($delay) 48 | { 49 | if ($delay instanceof DateInterval) { 50 | $delay = Carbon::now()->add($delay); 51 | } 52 | 53 | return $delay; 54 | } 55 | 56 | /** 57 | * Get the current system time as a UNIX timestamp. 58 | * 59 | * @return int 60 | */ 61 | protected function currentTime() 62 | { 63 | return Carbon::now()->getTimestamp(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/queue/Job.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | use Exception; 15 | use think\App; 16 | use think\helper\Arr; 17 | use think\helper\Str; 18 | 19 | abstract class Job 20 | { 21 | 22 | /** 23 | * The job handler instance. 24 | * @var object 25 | */ 26 | private $instance; 27 | 28 | /** 29 | * The JSON decoded version of "$job". 30 | * @var array 31 | */ 32 | private $payload; 33 | 34 | /** 35 | * @var App 36 | */ 37 | protected $app; 38 | 39 | /** 40 | * The name of the queue the job belongs to. 41 | * @var string 42 | */ 43 | protected $queue; 44 | 45 | /** 46 | * The name of the connection the job belongs to. 47 | */ 48 | protected $connection; 49 | 50 | /** 51 | * Indicates if the job has been deleted. 52 | * @var bool 53 | */ 54 | protected $deleted = false; 55 | 56 | /** 57 | * Indicates if the job has been released. 58 | * @var bool 59 | */ 60 | protected $released = false; 61 | 62 | /** 63 | * Indicates if the job has failed. 64 | * 65 | * @var bool 66 | */ 67 | protected $failed = false; 68 | 69 | /** 70 | * Get the decoded body of the job. 71 | * 72 | * @return mixed 73 | */ 74 | public function payload($name = null, $default = null) 75 | { 76 | if (empty($this->payload)) { 77 | $this->payload = json_decode($this->getRawBody(), true); 78 | } 79 | if (empty($name)) { 80 | return $this->payload; 81 | } 82 | return Arr::get($this->payload, $name, $default); 83 | } 84 | 85 | /** 86 | * Fire the job. 87 | * @return void 88 | */ 89 | public function fire() 90 | { 91 | $instance = $this->getResolvedJob(); 92 | 93 | [, $method] = $this->getParsedJob(); 94 | 95 | $instance->{$method}($this, $this->payload('data')); 96 | } 97 | 98 | /** 99 | * Process an exception that caused the job to fail. 100 | * 101 | * @param Exception $e 102 | * @return void 103 | */ 104 | public function failed($e) 105 | { 106 | $instance = $this->getResolvedJob(); 107 | 108 | if (method_exists($instance, 'failed')) { 109 | $instance->failed($this->payload('data'), $e); 110 | } 111 | } 112 | 113 | /** 114 | * Delete the job from the queue. 115 | * @return void 116 | */ 117 | public function delete() 118 | { 119 | $this->deleted = true; 120 | } 121 | 122 | /** 123 | * Determine if the job has been deleted. 124 | * @return bool 125 | */ 126 | public function isDeleted() 127 | { 128 | return $this->deleted; 129 | } 130 | 131 | /** 132 | * Release the job back into the queue. 133 | * @param int $delay 134 | * @return void 135 | */ 136 | public function release($delay = 0) 137 | { 138 | $this->released = true; 139 | } 140 | 141 | /** 142 | * Determine if the job was released back into the queue. 143 | * @return bool 144 | */ 145 | public function isReleased() 146 | { 147 | return $this->released; 148 | } 149 | 150 | /** 151 | * Determine if the job has been deleted or released. 152 | * @return bool 153 | */ 154 | public function isDeletedOrReleased() 155 | { 156 | return $this->isDeleted() || $this->isReleased(); 157 | } 158 | 159 | /** 160 | * Get the job identifier. 161 | * 162 | * @return string 163 | */ 164 | abstract public function getJobId(); 165 | 166 | /** 167 | * Get the number of times the job has been attempted. 168 | * @return int 169 | */ 170 | abstract public function attempts(); 171 | 172 | /** 173 | * Get the raw body string for the job. 174 | * @return string 175 | */ 176 | abstract public function getRawBody(); 177 | 178 | /** 179 | * Parse the job declaration into class and method. 180 | * @return array 181 | */ 182 | protected function getParsedJob() 183 | { 184 | $job = $this->payload('job'); 185 | $segments = explode('@', $job); 186 | 187 | return count($segments) > 1 ? $segments : [$segments[0], 'fire']; 188 | } 189 | 190 | /** 191 | * Resolve the given job handler. 192 | * @param string $name 193 | * @return mixed 194 | */ 195 | protected function resolve($name, $param) 196 | { 197 | $namespace = $this->app->getNamespace() . '\\job\\'; 198 | 199 | $class = false !== strpos($name, '\\') ? $name : $namespace . Str::studly($name); 200 | 201 | return $this->app->make($class, [$param], true); 202 | } 203 | 204 | public function getResolvedJob() 205 | { 206 | if (empty($this->instance)) { 207 | [$class] = $this->getParsedJob(); 208 | 209 | $this->instance = $this->resolve($class, $this->payload('data')); 210 | } 211 | 212 | return $this->instance; 213 | } 214 | 215 | /** 216 | * Determine if the job has been marked as a failure. 217 | * 218 | * @return bool 219 | */ 220 | public function hasFailed() 221 | { 222 | return $this->failed; 223 | } 224 | 225 | /** 226 | * Mark the job as "failed". 227 | * 228 | * @return void 229 | */ 230 | public function markAsFailed() 231 | { 232 | $this->failed = true; 233 | } 234 | 235 | /** 236 | * Get the number of times to attempt a job. 237 | * 238 | * @return int|null 239 | */ 240 | public function maxTries() 241 | { 242 | return $this->payload('maxTries'); 243 | } 244 | 245 | /** 246 | * Get the number of seconds the job can run. 247 | * 248 | * @return int|null 249 | */ 250 | public function timeout() 251 | { 252 | return $this->payload('timeout'); 253 | } 254 | 255 | /** 256 | * Get the timestamp indicating when the job should timeout. 257 | * 258 | * @return int|null 259 | */ 260 | public function timeoutAt() 261 | { 262 | return $this->payload('timeoutAt'); 263 | } 264 | 265 | /** 266 | * Get the name of the queued job class. 267 | * 268 | * @return string 269 | */ 270 | public function getName() 271 | { 272 | return $this->payload('job'); 273 | } 274 | 275 | /** 276 | * Get the name of the connection the job belongs to. 277 | * 278 | * @return string 279 | */ 280 | public function getConnection() 281 | { 282 | return $this->connection; 283 | } 284 | 285 | /** 286 | * Get the name of the queue the job belongs to. 287 | * @return string 288 | */ 289 | public function getQueue() 290 | { 291 | return $this->queue; 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/queue/Listener.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | use Closure; 15 | use Symfony\Component\Process\PhpExecutableFinder; 16 | use Symfony\Component\Process\Process; 17 | use think\App; 18 | 19 | class Listener 20 | { 21 | 22 | /** 23 | * @var string 24 | */ 25 | protected $commandPath; 26 | 27 | /** 28 | * @var string 29 | */ 30 | protected $workerCommand; 31 | 32 | /** 33 | * @var \Closure|null 34 | */ 35 | protected $outputHandler; 36 | 37 | /** 38 | * @param string $commandPath 39 | */ 40 | public function __construct($commandPath) 41 | { 42 | $this->commandPath = $commandPath; 43 | } 44 | 45 | public static function __make(App $app) 46 | { 47 | return new self($app->getRootPath()); 48 | } 49 | 50 | /** 51 | * Get the PHP binary. 52 | * 53 | * @return string 54 | */ 55 | protected function phpBinary() 56 | { 57 | return (new PhpExecutableFinder)->find(false); 58 | } 59 | 60 | /** 61 | * @param string $connection 62 | * @param string $queue 63 | * @param int $delay 64 | * @param int $sleep 65 | * @param int $maxTries 66 | * @param int $memory 67 | * @param int $timeout 68 | * @return void 69 | */ 70 | public function listen($connection, $queue, $delay = 0, $sleep = 3, $maxTries = 0, $memory = 128, $timeout = 60) 71 | { 72 | $process = $this->makeProcess($connection, $queue, $delay, $sleep, $maxTries, $memory, $timeout); 73 | 74 | while (true) { 75 | $this->runProcess($process, $memory); 76 | } 77 | } 78 | 79 | /** 80 | * @param string $connection 81 | * @param string $queue 82 | * @param int $delay 83 | * @param int $sleep 84 | * @param int $maxTries 85 | * @param int $memory 86 | * @param int $timeout 87 | * @return Process 88 | */ 89 | public function makeProcess($connection, $queue, $delay, $sleep, $maxTries, $memory, $timeout) 90 | { 91 | $command = array_filter([ 92 | $this->phpBinary(), 93 | 'think', 94 | 'queue:work', 95 | $connection, 96 | '--once', 97 | "--queue={$queue}", 98 | "--delay={$delay}", 99 | "--memory={$memory}", 100 | "--sleep={$sleep}", 101 | "--tries={$maxTries}", 102 | ], function ($value) { 103 | return !is_null($value); 104 | }); 105 | 106 | return new Process($command, $this->commandPath, null, null, $timeout); 107 | } 108 | 109 | /** 110 | * @param Process $process 111 | * @param int $memory 112 | */ 113 | public function runProcess(Process $process, $memory) 114 | { 115 | $process->run(function ($type, $line) { 116 | $this->handleWorkerOutput($type, $line); 117 | }); 118 | 119 | if ($this->memoryExceeded($memory)) { 120 | $this->stop(); 121 | } 122 | } 123 | 124 | /** 125 | * @param int $type 126 | * @param string $line 127 | * @return void 128 | */ 129 | protected function handleWorkerOutput($type, $line) 130 | { 131 | if (isset($this->outputHandler)) { 132 | call_user_func($this->outputHandler, $type, $line); 133 | } 134 | } 135 | 136 | /** 137 | * @param int $memoryLimit 138 | * @return bool 139 | */ 140 | public function memoryExceeded($memoryLimit) 141 | { 142 | return (memory_get_usage() / 1024 / 1024) >= $memoryLimit; 143 | } 144 | 145 | /** 146 | * @return void 147 | */ 148 | public function stop() 149 | { 150 | die; 151 | } 152 | 153 | /** 154 | * @param \Closure $outputHandler 155 | * @return void 156 | */ 157 | public function setOutputHandler(Closure $outputHandler) 158 | { 159 | $this->outputHandler = $outputHandler; 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /src/queue/Queueable.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | trait Queueable 15 | { 16 | 17 | /** @var string 连接 */ 18 | public $connection; 19 | 20 | /** @var string 队列名称 */ 21 | public $queue; 22 | 23 | /** @var integer 延迟时间 */ 24 | public $delay; 25 | 26 | /** 27 | * 设置连接名 28 | * @param $connection 29 | * @return $this 30 | */ 31 | public function onConnection($connection) 32 | { 33 | $this->connection = $connection; 34 | 35 | return $this; 36 | } 37 | 38 | /** 39 | * 设置队列名 40 | * @param $queue 41 | * @return $this 42 | */ 43 | public function onQueue($queue) 44 | { 45 | $this->queue = $queue; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * 设置延迟时间 52 | * @param $delay 53 | * @return $this 54 | */ 55 | public function delay($delay) 56 | { 57 | $this->delay = $delay; 58 | 59 | return $this; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/queue/Service.php: -------------------------------------------------------------------------------- 1 | app->bind('queue', Queue::class); 23 | $this->app->bind('queue.failer', function () { 24 | 25 | $config = $this->app->config->get('queue.failed', []); 26 | 27 | $type = Arr::pull($config, 'type', 'none'); 28 | 29 | $class = false !== strpos($type, '\\') ? $type : '\\think\\queue\\failed\\' . Str::studly($type); 30 | 31 | return $this->app->invokeClass($class, [$config]); 32 | }); 33 | } 34 | 35 | public function boot() 36 | { 37 | $this->commands([ 38 | FailedJob::class, 39 | Table::class, 40 | FlushFailed::class, 41 | ForgetFailed::class, 42 | ListFailed::class, 43 | Retry::class, 44 | Work::class, 45 | Restart::class, 46 | Listen::class, 47 | FailedTable::class, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/queue/ShouldQueue.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | interface ShouldQueue 15 | { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/queue/Worker.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue; 13 | 14 | use Carbon\Carbon; 15 | use Exception; 16 | use RuntimeException; 17 | use think\Cache; 18 | use think\Event; 19 | use think\exception\Handle; 20 | use think\Queue; 21 | use think\queue\event\JobExceptionOccurred; 22 | use think\queue\event\JobFailed; 23 | use think\queue\event\JobProcessed; 24 | use think\queue\event\JobProcessing; 25 | use think\queue\event\WorkerStopping; 26 | use think\queue\exception\MaxAttemptsExceededException; 27 | use Throwable; 28 | 29 | class Worker 30 | { 31 | /** @var Event */ 32 | protected $event; 33 | /** @var Handle */ 34 | protected $handle; 35 | /** @var Queue */ 36 | protected $queue; 37 | 38 | /** @var Cache */ 39 | protected $cache; 40 | 41 | /** 42 | * Indicates if the worker should exit. 43 | * 44 | * @var bool 45 | */ 46 | public $shouldQuit = false; 47 | 48 | /** 49 | * Indicates if the worker is paused. 50 | * 51 | * @var bool 52 | */ 53 | public $paused = false; 54 | 55 | public function __construct(Queue $queue, Event $event, Handle $handle, ?Cache $cache = null) 56 | { 57 | $this->queue = $queue; 58 | $this->event = $event; 59 | $this->handle = $handle; 60 | $this->cache = $cache; 61 | } 62 | 63 | /** 64 | * @param string $connection 65 | * @param string $queue 66 | * @param int $delay 67 | * @param int $sleep 68 | * @param int $maxTries 69 | * @param int $memory 70 | * @param int $timeout 71 | */ 72 | public function daemon($connection, $queue, $delay = 0, $sleep = 3, $maxTries = 0, $memory = 128, $timeout = 60) 73 | { 74 | if ($this->supportsAsyncSignals()) { 75 | $this->listenForSignals(); 76 | } 77 | 78 | $lastRestart = $this->getTimestampOfLastQueueRestart(); 79 | 80 | while (true) { 81 | 82 | $job = $this->getNextJob( 83 | $this->queue->connection($connection), 84 | $queue 85 | ); 86 | 87 | if ($this->supportsAsyncSignals()) { 88 | $this->registerTimeoutHandler($job, $timeout); 89 | } 90 | 91 | if ($job) { 92 | $this->runJob($job, $connection, $maxTries, $delay); 93 | } else { 94 | $this->sleep($sleep); 95 | } 96 | 97 | $this->stopIfNecessary($job, $lastRestart, $memory); 98 | } 99 | } 100 | 101 | protected function stopIfNecessary($job, $lastRestart, $memory) 102 | { 103 | if ($this->shouldQuit || $this->queueShouldRestart($lastRestart)) { 104 | $this->stop(); 105 | } elseif ($this->memoryExceeded($memory)) { 106 | $this->stop(12); 107 | } 108 | } 109 | 110 | /** 111 | * Determine if the queue worker should restart. 112 | * 113 | * @param int|null $lastRestart 114 | * @return bool 115 | */ 116 | protected function queueShouldRestart($lastRestart) 117 | { 118 | return $this->getTimestampOfLastQueueRestart() != $lastRestart; 119 | } 120 | 121 | /** 122 | * Determine if the memory limit has been exceeded. 123 | * 124 | * @param int $memoryLimit 125 | * @return bool 126 | */ 127 | public function memoryExceeded($memoryLimit) 128 | { 129 | return (memory_get_usage(true) / 1024 / 1024) >= $memoryLimit; 130 | } 131 | 132 | /** 133 | * 获取队列重启时间 134 | * @return mixed 135 | */ 136 | protected function getTimestampOfLastQueueRestart() 137 | { 138 | if ($this->cache) { 139 | return $this->cache->get('think:queue:restart'); 140 | } 141 | } 142 | 143 | /** 144 | * Register the worker timeout handler. 145 | * 146 | * @param Job|null $job 147 | * @param int $timeout 148 | * @return void 149 | */ 150 | protected function registerTimeoutHandler($job, $timeout) 151 | { 152 | pcntl_signal(SIGALRM, function () { 153 | $this->kill(1); 154 | }); 155 | 156 | pcntl_alarm( 157 | max($this->timeoutForJob($job, $timeout), 0) 158 | ); 159 | } 160 | 161 | /** 162 | * Stop listening and bail out of the script. 163 | * 164 | * @param int $status 165 | * @return void 166 | */ 167 | public function stop($status = 0) 168 | { 169 | $this->event->trigger(new WorkerStopping($status)); 170 | 171 | exit($status); 172 | } 173 | 174 | /** 175 | * Kill the process. 176 | * 177 | * @param int $status 178 | * @return void 179 | */ 180 | public function kill($status = 0) 181 | { 182 | $this->event->trigger(new WorkerStopping($status)); 183 | 184 | if (extension_loaded('posix')) { 185 | posix_kill(getmypid(), SIGKILL); 186 | } 187 | 188 | exit($status); 189 | } 190 | 191 | /** 192 | * Get the appropriate timeout for the given job. 193 | * 194 | * @param Job|null $job 195 | * @param int $timeout 196 | * @return int 197 | */ 198 | protected function timeoutForJob($job, $timeout) 199 | { 200 | return $job && !is_null($job->timeout()) ? $job->timeout() : $timeout; 201 | } 202 | 203 | /** 204 | * Determine if "async" signals are supported. 205 | * 206 | * @return bool 207 | */ 208 | protected function supportsAsyncSignals() 209 | { 210 | return extension_loaded('pcntl'); 211 | } 212 | 213 | /** 214 | * Enable async signals for the process. 215 | * 216 | * @return void 217 | */ 218 | protected function listenForSignals() 219 | { 220 | pcntl_async_signals(true); 221 | 222 | pcntl_signal(SIGTERM, function () { 223 | $this->shouldQuit = true; 224 | }); 225 | 226 | pcntl_signal(SIGUSR2, function () { 227 | $this->paused = true; 228 | }); 229 | 230 | pcntl_signal(SIGCONT, function () { 231 | $this->paused = false; 232 | }); 233 | } 234 | 235 | /** 236 | * 执行下个任务 237 | * @param string $connection 238 | * @param string $queue 239 | * @param int $delay 240 | * @param int $sleep 241 | * @param int $maxTries 242 | * @return void 243 | * @throws Exception 244 | */ 245 | public function runNextJob($connection, $queue, $delay = 0, $sleep = 3, $maxTries = 0) 246 | { 247 | 248 | $job = $this->getNextJob($this->queue->connection($connection), $queue); 249 | 250 | if ($job) { 251 | $this->runJob($job, $connection, $maxTries, $delay); 252 | } else { 253 | $this->sleep($sleep); 254 | } 255 | } 256 | 257 | /** 258 | * 执行任务 259 | * @param Job $job 260 | * @param string $connection 261 | * @param int $maxTries 262 | * @param int $delay 263 | * @return void 264 | */ 265 | protected function runJob($job, $connection, $maxTries, $delay) 266 | { 267 | try { 268 | $this->process($connection, $job, $maxTries, $delay); 269 | } catch (Exception | Throwable $e) { 270 | $this->handle->report($e); 271 | } 272 | } 273 | 274 | /** 275 | * 获取下个任务 276 | * @param Connector $connector 277 | * @param string $queue 278 | * @return Job 279 | */ 280 | protected function getNextJob($connector, $queue) 281 | { 282 | try { 283 | foreach (explode(',', $queue) as $queue) { 284 | if (!is_null($job = $connector->pop($queue))) { 285 | return $job; 286 | } 287 | } 288 | } catch (Exception | Throwable $e) { 289 | $this->handle->report($e); 290 | $this->sleep(1); 291 | } 292 | } 293 | 294 | /** 295 | * Process a given job from the queue. 296 | * @param string $connection 297 | * @param Job $job 298 | * @param int $maxTries 299 | * @param int $delay 300 | * @return void 301 | * @throws Exception 302 | */ 303 | public function process($connection, $job, $maxTries = 0, $delay = 0) 304 | { 305 | try { 306 | $this->event->trigger(new JobProcessing($connection, $job)); 307 | 308 | $this->markJobAsFailedIfAlreadyExceedsMaxAttempts( 309 | $connection, 310 | $job, 311 | (int) $maxTries 312 | ); 313 | 314 | $job->fire(); 315 | 316 | $this->event->trigger(new JobProcessed($connection, $job)); 317 | } catch (Exception | Throwable $e) { 318 | try { 319 | if (!$job->hasFailed()) { 320 | $this->markJobAsFailedIfWillExceedMaxAttempts($connection, $job, (int) $maxTries, $e); 321 | } 322 | 323 | $this->event->trigger(new JobExceptionOccurred($connection, $job, $e)); 324 | } finally { 325 | if (!$job->isDeleted() && !$job->isReleased() && !$job->hasFailed()) { 326 | $job->release($delay); 327 | } 328 | } 329 | 330 | throw $e; 331 | } 332 | } 333 | 334 | /** 335 | * @param string $connection 336 | * @param Job $job 337 | * @param int $maxTries 338 | */ 339 | protected function markJobAsFailedIfAlreadyExceedsMaxAttempts($connection, $job, $maxTries) 340 | { 341 | $maxTries = !is_null($job->maxTries()) ? $job->maxTries() : $maxTries; 342 | 343 | $timeoutAt = $job->timeoutAt(); 344 | 345 | if ($timeoutAt && Carbon::now()->getTimestamp() <= $timeoutAt) { 346 | return; 347 | } 348 | 349 | if (!$timeoutAt && (0 === $maxTries || $job->attempts() <= $maxTries)) { 350 | return; 351 | } 352 | 353 | $this->failJob($connection, $job, $e = new MaxAttemptsExceededException( 354 | $job->getName() . ' has been attempted too many times or run too long. The job may have previously timed out.' 355 | )); 356 | 357 | throw $e; 358 | } 359 | 360 | /** 361 | * @param string $connection 362 | * @param Job $job 363 | * @param int $maxTries 364 | * @param Exception $e 365 | */ 366 | protected function markJobAsFailedIfWillExceedMaxAttempts($connection, $job, $maxTries, $e) 367 | { 368 | $maxTries = !is_null($job->maxTries()) ? $job->maxTries() : $maxTries; 369 | 370 | if ($job->timeoutAt() && $job->timeoutAt() <= Carbon::now()->getTimestamp()) { 371 | $this->failJob($connection, $job, $e); 372 | } 373 | 374 | if ($maxTries > 0 && $job->attempts() >= $maxTries) { 375 | $this->failJob($connection, $job, $e); 376 | } 377 | } 378 | 379 | /** 380 | * @param string $connection 381 | * @param Job $job 382 | * @param Exception $e 383 | */ 384 | protected function failJob($connection, $job, $e) 385 | { 386 | $job->markAsFailed(); 387 | 388 | if ($job->isDeleted()) { 389 | return; 390 | } 391 | 392 | try { 393 | $job->delete(); 394 | 395 | $job->failed($e); 396 | } finally { 397 | $this->event->trigger(new JobFailed( 398 | $connection, 399 | $job, 400 | $e ?: new RuntimeException('ManuallyFailed') 401 | )); 402 | } 403 | } 404 | 405 | /** 406 | * Sleep the script for a given number of seconds. 407 | * @param int $seconds 408 | * @return void 409 | */ 410 | public function sleep($seconds) 411 | { 412 | if ($seconds < 1) { 413 | usleep($seconds * 1000000); 414 | } else { 415 | sleep($seconds); 416 | } 417 | } 418 | 419 | } 420 | -------------------------------------------------------------------------------- /src/queue/command/FailedTable.php: -------------------------------------------------------------------------------- 1 | setName('queue:failed-table') 14 | ->setDescription('Create a migration for the failed queue jobs database table'); 15 | } 16 | 17 | public function handle() 18 | { 19 | if (!$this->app->has('migration.creator')) { 20 | $this->output->error('Install think-migration first please'); 21 | return; 22 | } 23 | 24 | $table = $this->app->config->get('queue.failed.table'); 25 | 26 | $className = Str::studly("create_{$table}_table"); 27 | 28 | /** @var Creator $creator */ 29 | $creator = $this->app->get('migration.creator'); 30 | 31 | $path = $creator->create($className); 32 | 33 | // Load the alternative template if it is defined. 34 | $contents = file_get_contents(__DIR__ . '/stubs/failed_jobs.stub'); 35 | 36 | // inject the class names appropriate to this migration 37 | $contents = strtr($contents, [ 38 | 'CreateFailedJobsTable' => $className, 39 | '{{table}}' => $table, 40 | ]); 41 | 42 | file_put_contents($path, $contents); 43 | 44 | $this->output->info('Migration created successfully!'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/queue/command/FlushFailed.php: -------------------------------------------------------------------------------- 1 | setName('queue:flush') 12 | ->setDescription('Flush all of the failed queue jobs'); 13 | } 14 | 15 | public function handle() 16 | { 17 | $this->app->get('queue.failer')->flush(); 18 | 19 | $this->output->info('All failed jobs deleted successfully!'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/queue/command/ForgetFailed.php: -------------------------------------------------------------------------------- 1 | setName('queue:forget') 13 | ->addArgument('id', Argument::REQUIRED, 'The ID of the failed job') 14 | ->setDescription('Delete a failed queue job'); 15 | } 16 | 17 | public function handle() 18 | { 19 | if ($this->app['queue.failer']->forget($this->input->getArgument('id'))) { 20 | $this->output->info('Failed job deleted successfully!'); 21 | } else { 22 | $this->output->error('No failed job matches the given ID.'); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/queue/command/ListFailed.php: -------------------------------------------------------------------------------- 1 | setName('queue:failed') 21 | ->setDescription('List all of the failed queue jobs'); 22 | } 23 | 24 | public function handle() 25 | { 26 | if (count($jobs = $this->getFailedJobs()) === 0) { 27 | $this->output->info('No failed jobs!'); 28 | return; 29 | } 30 | $this->displayFailedJobs($jobs); 31 | } 32 | 33 | /** 34 | * Display the failed jobs in the console. 35 | * 36 | * @param array $jobs 37 | * @return void 38 | */ 39 | protected function displayFailedJobs(array $jobs) 40 | { 41 | $table = new Table(); 42 | $table->setHeader($this->headers); 43 | $table->setRows($jobs); 44 | 45 | $this->table($table); 46 | } 47 | 48 | /** 49 | * Compile the failed jobs into a displayable format. 50 | * 51 | * @return array 52 | */ 53 | protected function getFailedJobs() 54 | { 55 | $failed = $this->app['queue.failer']->all(); 56 | 57 | return collect($failed)->map(function ($failed) { 58 | return $this->parseFailedJob((array) $failed); 59 | })->filter()->all(); 60 | } 61 | 62 | /** 63 | * Parse the failed job row. 64 | * 65 | * @param array $failed 66 | * @return array 67 | */ 68 | protected function parseFailedJob(array $failed) 69 | { 70 | $row = array_values(Arr::except($failed, ['payload', 'exception'])); 71 | 72 | array_splice($row, 3, 0, $this->extractJobName($failed['payload'])); 73 | 74 | return $row; 75 | } 76 | 77 | /** 78 | * Extract the failed job name from payload. 79 | * 80 | * @param string $payload 81 | * @return string|null 82 | */ 83 | private function extractJobName($payload) 84 | { 85 | $payload = json_decode($payload, true); 86 | 87 | if ($payload && (!isset($payload['data']['command']))) { 88 | return $payload['job'] ?? null; 89 | } elseif ($payload && isset($payload['data']['command'])) { 90 | return $this->matchJobName($payload); 91 | } 92 | } 93 | 94 | /** 95 | * Match the job name from the payload. 96 | * 97 | * @param array $payload 98 | * @return string 99 | */ 100 | protected function matchJobName($payload) 101 | { 102 | preg_match('/"([^"]+)"/', $payload['data']['command'], $matches); 103 | 104 | if (isset($matches[1])) { 105 | return $matches[1]; 106 | } 107 | 108 | return $payload['job'] ?? null; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/queue/command/Listen.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\command; 13 | 14 | use think\console\Command; 15 | use think\console\Input; 16 | use think\console\input\Argument; 17 | use think\console\input\Option; 18 | use think\console\Output; 19 | use think\queue\Listener; 20 | 21 | class Listen extends Command 22 | { 23 | /** @var Listener */ 24 | protected $listener; 25 | 26 | public function __construct(Listener $listener) 27 | { 28 | parent::__construct(); 29 | $this->listener = $listener; 30 | $this->listener->setOutputHandler(function ($type, $line) { 31 | $this->output->write($line); 32 | }); 33 | } 34 | 35 | protected function configure() 36 | { 37 | $this->setName('queue:listen') 38 | ->addArgument('connection', Argument::OPTIONAL, 'The name of the queue connection to work', null) 39 | ->addOption('queue', null, Option::VALUE_OPTIONAL, 'The queue to listen on', null) 40 | ->addOption('delay', null, Option::VALUE_OPTIONAL, 'Amount of time to delay failed jobs', 0) 41 | ->addOption('memory', null, Option::VALUE_OPTIONAL, 'The memory limit in megabytes', 128) 42 | ->addOption('timeout', null, Option::VALUE_OPTIONAL, 'Seconds a job may run before timing out', 60) 43 | ->addOption('sleep', null, Option::VALUE_OPTIONAL, 'Seconds to wait before checking queue for jobs', 3) 44 | ->addOption('tries', null, Option::VALUE_OPTIONAL, 'Number of times to attempt a job before logging it failed', 0) 45 | ->setDescription('Listen to a given queue'); 46 | } 47 | 48 | public function execute(Input $input, Output $output) 49 | { 50 | $connection = $input->getArgument('connection') ?: $this->app->config->get('queue.default'); 51 | 52 | $queue = $input->getOption('queue') ?: $this->app->config->get("queue.connections.{$connection}.queue", 'default'); 53 | $delay = $input->getOption('delay'); 54 | $memory = $input->getOption('memory'); 55 | $timeout = $input->getOption('timeout'); 56 | $sleep = $input->getOption('sleep'); 57 | $tries = $input->getOption('tries'); 58 | 59 | $this->listener->listen($connection, $queue, $delay, $sleep, $tries, $memory, $timeout); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/queue/command/Restart.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\command; 13 | 14 | use think\Cache; 15 | use think\console\Command; 16 | use think\queue\InteractsWithTime; 17 | 18 | class Restart extends Command 19 | { 20 | use InteractsWithTime; 21 | 22 | protected function configure() 23 | { 24 | $this->setName('queue:restart') 25 | ->setDescription('Restart queue worker daemons after their current job'); 26 | } 27 | 28 | public function handle(Cache $cache) 29 | { 30 | $cache->set('think:queue:restart', $this->currentTime()); 31 | $this->output->info("Broadcasting queue restart signal."); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/queue/command/Retry.php: -------------------------------------------------------------------------------- 1 | setName('queue:retry') 15 | ->addArgument('id', Argument::IS_ARRAY | Argument::REQUIRED, 'The ID of the failed job or "all" to retry all jobs') 16 | ->setDescription('Retry a failed queue job'); 17 | } 18 | 19 | public function handle() 20 | { 21 | foreach ($this->getJobIds() as $id) { 22 | $job = $this->app['queue.failer']->find($id); 23 | 24 | if (is_null($job)) { 25 | $this->output->error("Unable to find failed job with ID [{$id}]."); 26 | } else { 27 | $this->retryJob($job); 28 | 29 | $this->output->info("The failed job [{$id}] has been pushed back onto the queue!"); 30 | 31 | $this->app['queue.failer']->forget($id); 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * Retry the queue job. 38 | * 39 | * @param stdClass $job 40 | * @return void 41 | */ 42 | protected function retryJob($job) 43 | { 44 | $this->app['queue']->connection($job['connection'])->pushRaw( 45 | $this->resetAttempts($job['payload']), 46 | $job['queue'] 47 | ); 48 | } 49 | 50 | /** 51 | * Reset the payload attempts. 52 | * 53 | * Applicable to Redis jobs which store attempts in their payload. 54 | * 55 | * @param string $payload 56 | * @return string 57 | */ 58 | protected function resetAttempts($payload) 59 | { 60 | $payload = json_decode($payload, true); 61 | 62 | if (isset($payload['attempts'])) { 63 | $payload['attempts'] = 0; 64 | } 65 | 66 | return json_encode($payload); 67 | } 68 | 69 | /** 70 | * Get the job IDs to be retried. 71 | * 72 | * @return array 73 | */ 74 | protected function getJobIds() 75 | { 76 | $ids = (array) $this->input->getArgument('id'); 77 | 78 | if (count($ids) === 1 && $ids[0] === 'all') { 79 | $ids = Arr::pluck($this->app['queue.failer']->all(), 'id'); 80 | } 81 | 82 | return $ids; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/queue/command/Table.php: -------------------------------------------------------------------------------- 1 | setName('queue:table') 14 | ->setDescription('Create a migration for the queue jobs database table'); 15 | } 16 | 17 | public function handle() 18 | { 19 | if (!$this->app->has('migration.creator')) { 20 | $this->output->error('Install think-migration first please'); 21 | return; 22 | } 23 | 24 | $table = $this->app->config->get('queue.connections.database.table'); 25 | 26 | $className = Str::studly("create_{$table}_table"); 27 | 28 | /** @var Creator $creator */ 29 | $creator = $this->app->get('migration.creator'); 30 | 31 | $path = $creator->create($className); 32 | 33 | // Load the alternative template if it is defined. 34 | $contents = file_get_contents(__DIR__ . '/stubs/jobs.stub'); 35 | 36 | // inject the class names appropriate to this migration 37 | $contents = strtr($contents, [ 38 | 'CreateJobsTable' => $className, 39 | '{{table}}' => $table, 40 | ]); 41 | 42 | file_put_contents($path, $contents); 43 | 44 | $this->output->info('Migration created successfully!'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/queue/command/Work.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | namespace think\queue\command; 12 | 13 | use think\console\Command; 14 | use think\console\Input; 15 | use think\console\input\Argument; 16 | use think\console\input\Option; 17 | use think\console\Output; 18 | use think\queue\event\JobFailed; 19 | use think\queue\event\JobProcessed; 20 | use think\queue\event\JobProcessing; 21 | use think\queue\Job; 22 | use think\queue\Worker; 23 | 24 | class Work extends Command 25 | { 26 | 27 | /** 28 | * The queue worker instance. 29 | * @var Worker 30 | */ 31 | protected $worker; 32 | 33 | public function __construct(Worker $worker) 34 | { 35 | parent::__construct(); 36 | $this->worker = $worker; 37 | } 38 | 39 | protected function configure() 40 | { 41 | $this->setName('queue:work') 42 | ->addArgument('connection', Argument::OPTIONAL, 'The name of the queue connection to work', null) 43 | ->addOption('queue', null, Option::VALUE_OPTIONAL, 'The queue to listen on') 44 | ->addOption('once', null, Option::VALUE_NONE, 'Only process the next job on the queue') 45 | ->addOption('delay', null, Option::VALUE_OPTIONAL, 'Amount of time to delay failed jobs', 0) 46 | ->addOption('force', null, Option::VALUE_NONE, 'Force the worker to run even in maintenance mode') 47 | ->addOption('memory', null, Option::VALUE_OPTIONAL, 'The memory limit in megabytes', 128) 48 | ->addOption('timeout', null, Option::VALUE_OPTIONAL, 'The number of seconds a child process can run', 60) 49 | ->addOption('sleep', null, Option::VALUE_OPTIONAL, 'Number of seconds to sleep when no job is available', 3) 50 | ->addOption('tries', null, Option::VALUE_OPTIONAL, 'Number of times to attempt a job before logging it failed', 0) 51 | ->setDescription('Process the next job on a queue'); 52 | } 53 | 54 | /** 55 | * Execute the console command. 56 | * @param Input $input 57 | * @param Output $output 58 | * @return int|null|void 59 | */ 60 | public function execute(Input $input, Output $output) 61 | { 62 | $connection = $input->getArgument('connection') ?: $this->app->config->get('queue.default'); 63 | 64 | $queue = $input->getOption('queue') ?: $this->app->config->get("queue.connections.{$connection}.queue", 'default'); 65 | $delay = $input->getOption('delay'); 66 | $sleep = $input->getOption('sleep'); 67 | $tries = $input->getOption('tries'); 68 | 69 | $this->listenForEvents(); 70 | 71 | if ($input->getOption('once')) { 72 | $this->worker->runNextJob($connection, $queue, $delay, $sleep, $tries); 73 | } else { 74 | $memory = $input->getOption('memory'); 75 | $timeout = $input->getOption('timeout'); 76 | $this->worker->daemon($connection, $queue, $delay, $sleep, $tries, $memory, $timeout); 77 | } 78 | } 79 | 80 | /** 81 | * 注册事件 82 | */ 83 | protected function listenForEvents() 84 | { 85 | $this->app->event->listen(JobProcessing::class, function (JobProcessing $event) { 86 | $this->writeOutput($event->job, 'starting'); 87 | }); 88 | 89 | $this->app->event->listen(JobProcessed::class, function (JobProcessed $event) { 90 | $this->writeOutput($event->job, 'success'); 91 | }); 92 | 93 | $this->app->event->listen(JobFailed::class, function (JobFailed $event) { 94 | $this->writeOutput($event->job, 'failed'); 95 | 96 | $this->logFailedJob($event); 97 | }); 98 | } 99 | 100 | /** 101 | * Write the status output for the queue worker. 102 | * 103 | * @param Job $job 104 | * @param $status 105 | */ 106 | protected function writeOutput(Job $job, $status) 107 | { 108 | switch ($status) { 109 | case 'starting': 110 | $this->writeStatus($job, 'Processing', 'comment'); 111 | break; 112 | case 'success': 113 | $this->writeStatus($job, 'Processed', 'info'); 114 | break; 115 | case 'failed': 116 | $this->writeStatus($job, 'Failed', 'error'); 117 | break; 118 | } 119 | } 120 | 121 | /** 122 | * Format the status output for the queue worker. 123 | * 124 | * @param Job $job 125 | * @param string $status 126 | * @param string $type 127 | * @return void 128 | */ 129 | protected function writeStatus(Job $job, $status, $type) 130 | { 131 | $this->output->writeln(sprintf( 132 | "<{$type}>[%s][%s] %s %s", 133 | date('Y-m-d H:i:s'), 134 | $job->getJobId(), 135 | str_pad("{$status}:", 11), 136 | $job->getName() 137 | )); 138 | } 139 | 140 | /** 141 | * 记录失败任务 142 | * @param JobFailed $event 143 | */ 144 | protected function logFailedJob(JobFailed $event) 145 | { 146 | $this->app['queue.failer']->log( 147 | $event->connection, 148 | $event->job->getQueue(), 149 | $event->job->getRawBody(), 150 | $event->exception 151 | ); 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /src/queue/command/stubs/failed_jobs.stub: -------------------------------------------------------------------------------- 1 | table('{{table}}') 11 | ->addColumn(Column::text('connection')) 12 | ->addColumn(Column::text('queue')) 13 | ->addColumn(Column::longText('payload')) 14 | ->addColumn(Column::longText('exception')) 15 | ->addColumn(Column::timestamp('fail_time')->setDefault('CURRENT_TIMESTAMP')) 16 | ->create(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/queue/command/stubs/jobs.stub: -------------------------------------------------------------------------------- 1 | table('{{table}}') 11 | ->addColumn(Column::string('queue')) 12 | ->addColumn(Column::longText('payload')) 13 | ->addColumn(Column::tinyInteger('attempts')->setUnsigned()) 14 | ->addColumn(Column::unsignedInteger('reserve_time')->setNullable()) 15 | ->addColumn(Column::unsignedInteger('available_time')) 16 | ->addColumn(Column::unsignedInteger('create_time')) 17 | ->addIndex('queue') 18 | ->create(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/queue/connector/Database.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\connector; 13 | 14 | use Carbon\Carbon; 15 | use stdClass; 16 | use think\Db; 17 | use think\db\ConnectionInterface; 18 | use think\db\Query; 19 | use think\queue\Connector; 20 | use think\queue\InteractsWithTime; 21 | use think\queue\job\Database as DatabaseJob; 22 | 23 | class Database extends Connector 24 | { 25 | 26 | use InteractsWithTime; 27 | 28 | protected $db; 29 | 30 | /** 31 | * The database table that holds the jobs. 32 | * 33 | * @var string 34 | */ 35 | protected $table; 36 | 37 | /** 38 | * The name of the default queue. 39 | * 40 | * @var string 41 | */ 42 | protected $default; 43 | 44 | /** 45 | * The expiration time of a job. 46 | * 47 | * @var int|null 48 | */ 49 | protected $retryAfter = 60; 50 | 51 | public function __construct(ConnectionInterface $db, $table, $default = 'default', $retryAfter = 60) 52 | { 53 | $this->db = $db; 54 | $this->table = $table; 55 | $this->default = $default; 56 | $this->retryAfter = $retryAfter; 57 | } 58 | 59 | public static function __make(Db $db, $config) 60 | { 61 | $connection = $db->connect($config['connection'] ?? null); 62 | 63 | return new self($connection, $config['table'], $config['queue'], $config['retry_after'] ?? 60); 64 | } 65 | 66 | public function size($queue = null) 67 | { 68 | return $this->db 69 | ->name($this->table) 70 | ->where('queue', $this->getQueue($queue)) 71 | ->count(); 72 | } 73 | 74 | public function push($job, $data = '', $queue = null) 75 | { 76 | return $this->pushToDatabase($queue, $this->createPayload($job, $data)); 77 | } 78 | 79 | public function pushRaw($payload, $queue = null, array $options = []) 80 | { 81 | return $this->pushToDatabase($queue, $payload); 82 | } 83 | 84 | public function later($delay, $job, $data = '', $queue = null) 85 | { 86 | return $this->pushToDatabase($queue, $this->createPayload($job, $data), $delay); 87 | } 88 | 89 | public function bulk($jobs, $data = '', $queue = null) 90 | { 91 | $queue = $this->getQueue($queue); 92 | 93 | $availableAt = $this->availableAt(); 94 | 95 | return $this->db->name($this->table)->insertAll(collect((array) $jobs)->map( 96 | function ($job) use ($queue, $data, $availableAt) { 97 | return [ 98 | 'queue' => $queue, 99 | 'attempts' => 0, 100 | 'reserve_time' => null, 101 | 'available_time' => $availableAt, 102 | 'create_time' => $this->currentTime(), 103 | 'payload' => $this->createPayload($job, $data), 104 | ]; 105 | } 106 | )->all()); 107 | } 108 | 109 | /** 110 | * 重新发布任务 111 | * 112 | * @param string $queue 113 | * @param StdClass $job 114 | * @param int $delay 115 | * @return mixed 116 | */ 117 | public function release($queue, $job, $delay) 118 | { 119 | return $this->pushToDatabase($queue, $job->payload, $delay, $job->attempts); 120 | } 121 | 122 | /** 123 | * Push a raw payload to the database with a given delay. 124 | * 125 | * @param \DateTime|int $delay 126 | * @param string|null $queue 127 | * @param string $payload 128 | * @param int $attempts 129 | * @return mixed 130 | */ 131 | protected function pushToDatabase($queue, $payload, $delay = 0, $attempts = 0) 132 | { 133 | return $this->db->name($this->table)->insertGetId([ 134 | 'queue' => $this->getQueue($queue), 135 | 'attempts' => $attempts, 136 | 'reserve_time' => null, 137 | 'available_time' => $this->availableAt($delay), 138 | 'create_time' => $this->currentTime(), 139 | 'payload' => $payload, 140 | ]); 141 | } 142 | 143 | public function pop($queue = null) 144 | { 145 | $queue = $this->getQueue($queue); 146 | 147 | return $this->db->transaction(function () use ($queue) { 148 | 149 | if ($job = $this->getNextAvailableJob($queue)) { 150 | 151 | $job = $this->markJobAsReserved($job); 152 | 153 | return new DatabaseJob($this->app, $this, $job, $this->connection, $queue); 154 | } 155 | }); 156 | } 157 | 158 | /** 159 | * 获取下个有效任务 160 | * 161 | * @param string|null $queue 162 | * @return StdClass|null 163 | */ 164 | protected function getNextAvailableJob($queue) 165 | { 166 | 167 | $job = $this->db 168 | ->name($this->table) 169 | ->lock(true) 170 | ->where('queue', $this->getQueue($queue)) 171 | ->where(function (Query $query) { 172 | $query->where(function (Query $query) { 173 | $query->whereNull('reserve_time') 174 | ->where('available_time', '<=', $this->currentTime()); 175 | }); 176 | 177 | //超时任务重试 178 | $expiration = Carbon::now()->subSeconds($this->retryAfter)->getTimestamp(); 179 | 180 | $query->whereOr(function (Query $query) use ($expiration) { 181 | $query->where('reserve_time', '<=', $expiration); 182 | }); 183 | }) 184 | ->order('id', 'asc') 185 | ->find(); 186 | 187 | return $job ? (object) $job : null; 188 | } 189 | 190 | /** 191 | * 标记任务正在执行. 192 | * 193 | * @param stdClass $job 194 | * @return stdClass 195 | */ 196 | protected function markJobAsReserved($job) 197 | { 198 | $this->db 199 | ->name($this->table) 200 | ->where('id', $job->id) 201 | ->update([ 202 | 'reserve_time' => $job->reserve_time = $this->currentTime(), 203 | 'attempts' => ++$job->attempts, 204 | ]); 205 | 206 | return $job; 207 | } 208 | 209 | /** 210 | * 删除任务 211 | * 212 | * @param string $id 213 | * @return void 214 | */ 215 | public function deleteReserved($id) 216 | { 217 | $this->db->transaction(function () use ($id) { 218 | if ($this->db->name($this->table)->lock(true)->find($id)) { 219 | $this->db->name($this->table)->where('id', $id)->delete(); 220 | } 221 | }); 222 | } 223 | 224 | protected function getQueue($queue) 225 | { 226 | return $queue ?: $this->default; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/queue/connector/Redis.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\connector; 13 | 14 | use Closure; 15 | use Exception; 16 | use RedisException; 17 | use think\helper\Str; 18 | use think\queue\Connector; 19 | use think\queue\InteractsWithTime; 20 | use think\queue\job\Redis as RedisJob; 21 | 22 | class Redis extends Connector 23 | { 24 | use InteractsWithTime; 25 | 26 | /** @var \Redis */ 27 | protected $redis; 28 | 29 | /** 30 | * The name of the default queue. 31 | * 32 | * @var string 33 | */ 34 | protected $default; 35 | 36 | /** 37 | * The expiration time of a job. 38 | * 39 | * @var int|null 40 | */ 41 | protected $retryAfter = 60; 42 | 43 | /** 44 | * The maximum number of seconds to block for a job. 45 | * 46 | * @var int|null 47 | */ 48 | protected $blockFor = null; 49 | 50 | public function __construct($redis, $default = 'default', $retryAfter = 60, $blockFor = null) 51 | { 52 | $this->redis = $redis; 53 | $this->default = $default; 54 | $this->retryAfter = $retryAfter; 55 | $this->blockFor = $blockFor; 56 | } 57 | 58 | public static function __make($config) 59 | { 60 | if (!extension_loaded('redis')) { 61 | throw new Exception('redis扩展未安装'); 62 | } 63 | 64 | $redis = new class($config) { 65 | protected $config; 66 | protected $client; 67 | 68 | public function __construct($config) 69 | { 70 | $this->config = $config; 71 | $this->client = $this->createClient(); 72 | } 73 | 74 | protected function createClient() 75 | { 76 | $config = $this->config; 77 | $func = $config['persistent'] ? 'pconnect' : 'connect'; 78 | 79 | $client = new \Redis; 80 | $client->$func($config['host'], $config['port'], $config['timeout']); 81 | 82 | if ('' != $config['password']) { 83 | $client->auth($config['password']); 84 | } 85 | 86 | if (0 != $config['select']) { 87 | $client->select($config['select']); 88 | } 89 | return $client; 90 | } 91 | 92 | public function __call($name, $arguments) 93 | { 94 | try { 95 | return call_user_func_array([$this->client, $name], $arguments); 96 | } catch (RedisException $e) { 97 | if (Str::contains($e->getMessage(), 'went away')) { 98 | $this->client = $this->createClient(); 99 | } 100 | 101 | throw $e; 102 | } 103 | } 104 | }; 105 | 106 | return new self($redis, $config['queue'], $config['retry_after'] ?? 60, $config['block_for'] ?? null); 107 | } 108 | 109 | public function size($queue = null) 110 | { 111 | $queue = $this->getQueue($queue); 112 | 113 | return $this->redis->lLen($queue) + $this->redis->zCard("{$queue}:delayed") + $this->redis->zCard("{$queue}:reserved"); 114 | } 115 | 116 | public function push($job, $data = '', $queue = null) 117 | { 118 | return $this->pushRaw($this->createPayload($job, $data), $queue); 119 | } 120 | 121 | public function pushRaw($payload, $queue = null, array $options = []) 122 | { 123 | if ($this->redis->rPush($this->getQueue($queue), $payload)) { 124 | return json_decode($payload, true)['id'] ?? null; 125 | } 126 | } 127 | 128 | public function later($delay, $job, $data = '', $queue = null) 129 | { 130 | return $this->laterRaw($delay, $this->createPayload($job, $data), $queue); 131 | } 132 | 133 | protected function laterRaw($delay, $payload, $queue = null) 134 | { 135 | if ($this->redis->zadd( 136 | $this->getQueue($queue) . ':delayed', 137 | $this->availableAt($delay), 138 | $payload 139 | )) { 140 | return json_decode($payload, true)['id'] ?? null; 141 | } 142 | } 143 | 144 | public function pop($queue = null) 145 | { 146 | $this->migrate($prefixed = $this->getQueue($queue)); 147 | 148 | if (empty($nextJob = $this->retrieveNextJob($prefixed))) { 149 | return; 150 | } 151 | 152 | [$job, $reserved] = $nextJob; 153 | 154 | if ($reserved) { 155 | return new RedisJob($this->app, $this, $job, $reserved, $this->connection, $queue); 156 | } 157 | } 158 | 159 | /** 160 | * Migrate any delayed or expired jobs onto the primary queue. 161 | * 162 | * @param string $queue 163 | * @return void 164 | */ 165 | protected function migrate($queue) 166 | { 167 | $this->migrateExpiredJobs($queue . ':delayed', $queue); 168 | 169 | if (!is_null($this->retryAfter)) { 170 | $this->migrateExpiredJobs($queue . ':reserved', $queue); 171 | } 172 | } 173 | 174 | /** 175 | * 移动延迟任务 176 | * 177 | * @param string $from 178 | * @param string $to 179 | * @param bool $attempt 180 | */ 181 | public function migrateExpiredJobs($from, $to, $attempt = true) 182 | { 183 | $this->redis->watch($from); 184 | 185 | $jobs = $this->redis->zRangeByScore($from, '-inf', $this->currentTime()); 186 | 187 | if (!empty($jobs)) { 188 | $this->transaction(function () use ($from, $to, $jobs, $attempt) { 189 | 190 | $this->redis->zRemRangeByRank($from, 0, count($jobs) - 1); 191 | 192 | for ($i = 0; $i < count($jobs); $i += 100) { 193 | 194 | $values = array_slice($jobs, $i, 100); 195 | 196 | $this->redis->rPush($to, ...$values); 197 | } 198 | }); 199 | } 200 | 201 | $this->redis->unwatch(); 202 | } 203 | 204 | /** 205 | * Retrieve the next job from the queue. 206 | * 207 | * @param string $queue 208 | * @return array 209 | */ 210 | protected function retrieveNextJob($queue) 211 | { 212 | if (!is_null($this->blockFor)) { 213 | return $this->blockingPop($queue); 214 | } 215 | 216 | $job = $this->redis->lpop($queue); 217 | $reserved = false; 218 | 219 | if ($job) { 220 | $reserved = json_decode($job, true); 221 | $reserved['attempts']++; 222 | $reserved = json_encode($reserved); 223 | $this->redis->zAdd($queue . ':reserved', $this->availableAt($this->retryAfter), $reserved); 224 | } 225 | 226 | return [$job, $reserved]; 227 | } 228 | 229 | /** 230 | * Retrieve the next job by blocking-pop. 231 | * 232 | * @param string $queue 233 | * @return array 234 | */ 235 | protected function blockingPop($queue) 236 | { 237 | $rawBody = $this->redis->blpop($queue, $this->blockFor); 238 | 239 | if (!empty($rawBody)) { 240 | $payload = json_decode($rawBody[1], true); 241 | 242 | $payload['attempts']++; 243 | 244 | $reserved = json_encode($payload); 245 | 246 | $this->redis->zadd($queue . ':reserved', $this->availableAt($this->retryAfter), $reserved); 247 | 248 | return [$rawBody[1], $reserved]; 249 | } 250 | 251 | return [null, null]; 252 | } 253 | 254 | /** 255 | * 删除任务 256 | * 257 | * @param string $queue 258 | * @param RedisJob $job 259 | * @return void 260 | */ 261 | public function deleteReserved($queue, $job) 262 | { 263 | $this->redis->zRem($this->getQueue($queue) . ':reserved', $job->getReservedJob()); 264 | } 265 | 266 | /** 267 | * Delete a reserved job from the reserved queue and release it. 268 | * 269 | * @param string $queue 270 | * @param RedisJob $job 271 | * @param int $delay 272 | * @return void 273 | */ 274 | public function deleteAndRelease($queue, $job, $delay) 275 | { 276 | $queue = $this->getQueue($queue); 277 | 278 | $reserved = $job->getReservedJob(); 279 | 280 | $this->redis->zRem($queue . ':reserved', $reserved); 281 | 282 | $this->redis->zAdd($queue . ':delayed', $this->availableAt($delay), $reserved); 283 | } 284 | 285 | /** 286 | * redis事务 287 | * @param Closure $closure 288 | */ 289 | protected function transaction(Closure $closure) 290 | { 291 | $this->redis->multi(); 292 | try { 293 | call_user_func($closure); 294 | if (!$this->redis->exec()) { 295 | $this->redis->discard(); 296 | } 297 | } catch (Exception $e) { 298 | $this->redis->discard(); 299 | } 300 | } 301 | 302 | protected function createPayloadArray($job, $data = '') 303 | { 304 | return array_merge(parent::createPayloadArray($job, $data), [ 305 | 'id' => $this->getRandomId(), 306 | 'attempts' => 0, 307 | ]); 308 | } 309 | 310 | /** 311 | * 随机id 312 | * 313 | * @return string 314 | */ 315 | protected function getRandomId() 316 | { 317 | return Str::random(32); 318 | } 319 | 320 | /** 321 | * 获取队列名 322 | * 323 | * @param string|null $queue 324 | * @return string 325 | */ 326 | protected function getQueue($queue) 327 | { 328 | $queue = $queue ?: $this->default; 329 | return "{queues:{$queue}}"; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/queue/connector/Sync.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\connector; 13 | 14 | use Exception; 15 | use think\queue\Connector; 16 | use think\queue\event\JobFailed; 17 | use think\queue\event\JobProcessed; 18 | use think\queue\event\JobProcessing; 19 | use think\queue\job\Sync as SyncJob; 20 | use Throwable; 21 | 22 | class Sync extends Connector 23 | { 24 | 25 | public function size($queue = null) 26 | { 27 | return 0; 28 | } 29 | 30 | public function push($job, $data = '', $queue = null) 31 | { 32 | $queueJob = $this->resolveJob($this->createPayload($job, $data), $queue); 33 | 34 | try { 35 | $this->triggerEvent(new JobProcessing($this->connection, $job)); 36 | 37 | $queueJob->fire(); 38 | 39 | $this->triggerEvent(new JobProcessed($this->connection, $job)); 40 | } catch (Exception | Throwable $e) { 41 | 42 | $this->triggerEvent(new JobFailed($this->connection, $job, $e)); 43 | 44 | throw $e; 45 | } 46 | 47 | return 0; 48 | } 49 | 50 | protected function triggerEvent($event) 51 | { 52 | $this->app->event->trigger($event); 53 | } 54 | 55 | public function pop($queue = null) 56 | { 57 | 58 | } 59 | 60 | protected function resolveJob($payload, $queue) 61 | { 62 | return new SyncJob($this->app, $payload, $this->connection, $queue); 63 | } 64 | 65 | public function pushRaw($payload, $queue = null, array $options = []) 66 | { 67 | 68 | } 69 | 70 | public function later($delay, $job, $data = '', $queue = null) 71 | { 72 | return $this->push($job, $data, $queue); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/queue/event/JobExceptionOccurred.php: -------------------------------------------------------------------------------- 1 | job = $job; 42 | $this->exception = $exception; 43 | $this->connectionName = $connectionName; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/queue/event/JobFailed.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 21 | $this->job = $job; 22 | $this->exception = $exception; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/queue/event/JobProcessed.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 18 | $this->job = $job; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/queue/event/JobProcessing.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 18 | $this->job = $job; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/queue/event/WorkerStopping.php: -------------------------------------------------------------------------------- 1 | status = $status; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/queue/exception/MaxAttemptsExceededException.php: -------------------------------------------------------------------------------- 1 | db = $db; 25 | $this->table = $table; 26 | } 27 | 28 | public static function __make(Db $db, $config) 29 | { 30 | return new self($db, $config['table']); 31 | } 32 | 33 | /** 34 | * Log a failed job into storage. 35 | * 36 | * @param string $connection 37 | * @param string $queue 38 | * @param string $payload 39 | * @param \Exception $exception 40 | * @return int|null 41 | */ 42 | public function log($connection, $queue, $payload, $exception) 43 | { 44 | $fail_time = Carbon::now()->toDateTimeString(); 45 | 46 | $exception = (string) $exception; 47 | 48 | return $this->getTable()->insertGetId(compact( 49 | 'connection', 50 | 'queue', 51 | 'payload', 52 | 'exception', 53 | 'fail_time' 54 | )); 55 | } 56 | 57 | /** 58 | * Get a list of all of the failed jobs. 59 | * 60 | * @return array 61 | */ 62 | public function all() 63 | { 64 | return collect($this->getTable()->order('id', 'desc')->select())->all(); 65 | } 66 | 67 | /** 68 | * Get a single failed job. 69 | * 70 | * @param mixed $id 71 | * @return object|null 72 | */ 73 | public function find($id) 74 | { 75 | return $this->getTable()->find($id); 76 | } 77 | 78 | /** 79 | * Delete a single failed job from storage. 80 | * 81 | * @param mixed $id 82 | * @return bool 83 | */ 84 | public function forget($id) 85 | { 86 | return $this->getTable()->where('id', $id)->delete() > 0; 87 | } 88 | 89 | /** 90 | * Flush all of the failed jobs from storage. 91 | * 92 | * @return void 93 | */ 94 | public function flush() 95 | { 96 | $this->getTable()->delete(true); 97 | } 98 | 99 | protected function getTable() 100 | { 101 | return $this->db->name($this->table); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/queue/failed/None.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | namespace think\queue\job; 12 | 13 | use think\App; 14 | use think\queue\connector\Database as DatabaseQueue; 15 | use think\queue\Job; 16 | 17 | class Database extends Job 18 | { 19 | /** 20 | * The database queue instance. 21 | * @var DatabaseQueue 22 | */ 23 | protected $database; 24 | 25 | /** 26 | * The database job payload. 27 | * @var Object 28 | */ 29 | protected $job; 30 | 31 | public function __construct(App $app, DatabaseQueue $database, $job, $connection, $queue) 32 | { 33 | $this->app = $app; 34 | $this->job = $job; 35 | $this->queue = $queue; 36 | $this->database = $database; 37 | $this->connection = $connection; 38 | } 39 | 40 | /** 41 | * 删除任务 42 | * @return void 43 | */ 44 | public function delete() 45 | { 46 | parent::delete(); 47 | $this->database->deleteReserved($this->job->id); 48 | } 49 | 50 | /** 51 | * 重新发布任务 52 | * @param int $delay 53 | * @return void 54 | */ 55 | public function release($delay = 0) 56 | { 57 | parent::release($delay); 58 | 59 | $this->delete(); 60 | 61 | $this->database->release($this->queue, $this->job, $delay); 62 | } 63 | 64 | /** 65 | * 获取当前任务尝试次数 66 | * @return int 67 | */ 68 | public function attempts() 69 | { 70 | return (int) $this->job->attempts; 71 | } 72 | 73 | /** 74 | * Get the raw body string for the job. 75 | * @return string 76 | */ 77 | public function getRawBody() 78 | { 79 | return $this->job->payload; 80 | } 81 | 82 | /** 83 | * Get the job identifier. 84 | * 85 | * @return string 86 | */ 87 | public function getJobId() 88 | { 89 | return $this->job->id; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/queue/job/Redis.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\job; 13 | 14 | use think\App; 15 | use think\queue\connector\Redis as RedisQueue; 16 | use think\queue\Job; 17 | 18 | class Redis extends Job 19 | { 20 | 21 | /** 22 | * The redis queue instance. 23 | * @var RedisQueue 24 | */ 25 | protected $redis; 26 | 27 | /** 28 | * The database job payload. 29 | * @var Object 30 | */ 31 | protected $job; 32 | 33 | /** 34 | * The Redis job payload inside the reserved queue. 35 | * 36 | * @var string 37 | */ 38 | protected $reserved; 39 | 40 | public function __construct(App $app, RedisQueue $redis, $job, $reserved, $connection, $queue) 41 | { 42 | $this->app = $app; 43 | $this->job = $job; 44 | $this->queue = $queue; 45 | $this->connection = $connection; 46 | $this->redis = $redis; 47 | $this->reserved = $reserved; 48 | } 49 | 50 | /** 51 | * Get the number of times the job has been attempted. 52 | * @return int 53 | */ 54 | public function attempts() 55 | { 56 | return $this->payload('attempts') + 1; 57 | } 58 | 59 | /** 60 | * Get the raw body string for the job. 61 | * @return string 62 | */ 63 | public function getRawBody() 64 | { 65 | return $this->job; 66 | } 67 | 68 | /** 69 | * 删除任务 70 | * 71 | * @return void 72 | */ 73 | public function delete() 74 | { 75 | parent::delete(); 76 | 77 | $this->redis->deleteReserved($this->queue, $this); 78 | } 79 | 80 | /** 81 | * 重新发布任务 82 | * 83 | * @param int $delay 84 | * @return void 85 | */ 86 | public function release($delay = 0) 87 | { 88 | parent::release($delay); 89 | 90 | $this->redis->deleteAndRelease($this->queue, $this, $delay); 91 | } 92 | 93 | /** 94 | * Get the job identifier. 95 | * 96 | * @return string 97 | */ 98 | public function getJobId() 99 | { 100 | return $this->payload('id'); 101 | } 102 | 103 | /** 104 | * Get the underlying reserved Redis job. 105 | * 106 | * @return string 107 | */ 108 | public function getReservedJob() 109 | { 110 | return $this->reserved; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/queue/job/Sync.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace think\queue\job; 13 | 14 | use think\App; 15 | use think\queue\Job; 16 | 17 | class Sync extends Job 18 | { 19 | /** 20 | * The queue message data. 21 | * 22 | * @var string 23 | */ 24 | protected $job; 25 | 26 | public function __construct(App $app, $job, $connection, $queue) 27 | { 28 | $this->app = $app; 29 | $this->connection = $connection; 30 | $this->queue = $queue; 31 | $this->job = $job; 32 | } 33 | 34 | /** 35 | * Get the number of times the job has been attempted. 36 | * @return int 37 | */ 38 | public function attempts() 39 | { 40 | return 1; 41 | } 42 | 43 | /** 44 | * Get the raw body string for the job. 45 | * @return string 46 | */ 47 | public function getRawBody() 48 | { 49 | return $this->job; 50 | } 51 | 52 | /** 53 | * Get the job identifier. 54 | * 55 | * @return string 56 | */ 57 | public function getJobId() 58 | { 59 | return ''; 60 | } 61 | 62 | public function getQueue() 63 | { 64 | return 'sync'; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/DatabaseConnectorTest.php: -------------------------------------------------------------------------------- 1 | db = m::mock(Db::class); 26 | $this->connector = new Database($this->db, 'table', 'default'); 27 | } 28 | 29 | public function testPushProperlyPushesJobOntoDatabase() 30 | { 31 | $this->db->shouldReceive('name')->with('table')->andReturn($query = m::mock(stdClass::class)); 32 | 33 | $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { 34 | $this->assertEquals('default', $array['queue']); 35 | $this->assertEquals(json_encode(['job' => 'foo', 'maxTries' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); 36 | $this->assertEquals(0, $array['attempts']); 37 | $this->assertNull($array['reserved_at']); 38 | $this->assertInternalType('int', $array['available_at']); 39 | }); 40 | $this->connector->push('foo', ['data']); 41 | } 42 | 43 | public function testDelayedPushProperlyPushesJobOntoDatabase() 44 | { 45 | $this->db->shouldReceive('name')->with('table')->andReturn($query = m::mock(stdClass::class)); 46 | 47 | $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { 48 | $this->assertEquals('default', $array['queue']); 49 | $this->assertEquals(json_encode(['job' => 'foo', 'maxTries' => null, 'timeout' => null, 'data' => ['data']]), $array['payload']); 50 | $this->assertEquals(0, $array['attempts']); 51 | $this->assertNull($array['reserved_at']); 52 | $this->assertInternalType('int', $array['available_at']); 53 | }); 54 | 55 | $this->connector->later(10, 'foo', ['data']); 56 | } 57 | 58 | public function testFailureToCreatePayloadFromObject() 59 | { 60 | $this->expectException('InvalidArgumentException'); 61 | 62 | $job = new stdClass; 63 | $job->invalid = "\xc3\x28"; 64 | 65 | $queue = $this->getMockForAbstractClass(Connector::class); 66 | $class = new ReflectionClass(Connector::class); 67 | 68 | $createPayload = $class->getMethod('createPayload'); 69 | $createPayload->setAccessible(true); 70 | $createPayload->invokeArgs($queue, [ 71 | $job, 72 | 'queue-name', 73 | ]); 74 | } 75 | 76 | public function testFailureToCreatePayloadFromArray() 77 | { 78 | $this->expectException('InvalidArgumentException'); 79 | 80 | $queue = $this->getMockForAbstractClass(Connector::class); 81 | $class = new ReflectionClass(Connector::class); 82 | 83 | $createPayload = $class->getMethod('createPayload'); 84 | $createPayload->setAccessible(true); 85 | $createPayload->invokeArgs($queue, [ 86 | ["\xc3\x28"], 87 | 'queue-name', 88 | ]); 89 | } 90 | 91 | public function testBulkBatchPushesOntoDatabase() 92 | { 93 | 94 | $this->db->shouldReceive('name')->with('table')->andReturn($query = m::mock(stdClass::class)); 95 | 96 | Carbon::setTestNow( 97 | $now = Carbon::now()->addSeconds() 98 | ); 99 | 100 | $query->shouldReceive('insertAll')->once()->andReturnUsing(function ($records) use ($now) { 101 | $this->assertEquals([ 102 | [ 103 | 'queue' => 'queue', 104 | 'payload' => json_encode(['job' => 'foo', 'maxTries' => null, 'timeout' => null, 'data' => ['data']]), 105 | 'attempts' => 0, 106 | 'reserved_at' => null, 107 | 'available_at' => $now->getTimestamp(), 108 | 'created_at' => $now->getTimestamp(), 109 | ], [ 110 | 'queue' => 'queue', 111 | 'payload' => json_encode(['job' => 'bar', 'maxTries' => null, 'timeout' => null, 'data' => ['data']]), 112 | 'attempts' => 0, 113 | 'reserved_at' => null, 114 | 'available_at' => $now->getTimestamp(), 115 | 'created_at' => $now->getTimestamp(), 116 | ], 117 | ], $records); 118 | }); 119 | 120 | $this->connector->bulk(['foo', 'bar'], ['data'], 'queue'); 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /tests/ListenerTest.php: -------------------------------------------------------------------------------- 1 | makePartial(); 21 | $process->shouldReceive('run')->once(); 22 | /** @var Listener|MockInterface $listener */ 23 | $listener = m::mock(Listener::class)->makePartial(); 24 | $listener->shouldReceive('memoryExceeded')->once()->with(1)->andReturn(false); 25 | 26 | $listener->runProcess($process, 1); 27 | } 28 | 29 | public function testListenerStopsWhenMemoryIsExceeded() 30 | { 31 | /** @var Process|MockInterface $process */ 32 | $process = m::mock(Process::class)->makePartial(); 33 | $process->shouldReceive('run')->once(); 34 | /** @var Listener|MockInterface $listener */ 35 | $listener = m::mock(Listener::class)->makePartial(); 36 | $listener->shouldReceive('memoryExceeded')->once()->with(1)->andReturn(true); 37 | $listener->shouldReceive('stop')->once(); 38 | 39 | $listener->runProcess($process, 1); 40 | } 41 | 42 | public function testMakeProcessCorrectlyFormatsCommandLine() 43 | { 44 | $listener = new Listener(__DIR__); 45 | 46 | $process = $listener->makeProcess('connection', 'queue', 1, 3, 0, 2, 3); 47 | $escape = '\\' === DIRECTORY_SEPARATOR ? '"' : '\''; 48 | 49 | $this->assertInstanceOf(Process::class, $process); 50 | $this->assertEquals(__DIR__, $process->getWorkingDirectory()); 51 | $this->assertEquals(3, $process->getTimeout()); 52 | $this->assertEquals($escape . PHP_BINARY . $escape . " {$escape}think{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=0{$escape}", $process->getCommandLine()); 53 | } 54 | 55 | public function testMakeProcessCorrectlyFormatsCommandLineWithAnEnvironmentSpecified() 56 | { 57 | $listener = new Listener(__DIR__); 58 | 59 | $process = $listener->makeProcess('connection', 'queue', 1, 3, 0, 2, 3); 60 | $escape = '\\' === DIRECTORY_SEPARATOR ? '"' : '\''; 61 | 62 | $this->assertInstanceOf(Process::class, $process); 63 | $this->assertEquals(__DIR__, $process->getWorkingDirectory()); 64 | $this->assertEquals(3, $process->getTimeout()); 65 | $this->assertEquals($escape . PHP_BINARY . $escape . " {$escape}think{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=0{$escape}", $process->getCommandLine()); 66 | } 67 | 68 | public function testMakeProcessCorrectlyFormatsCommandLineWhenTheConnectionIsNotSpecified() 69 | { 70 | $listener = new Listener(__DIR__); 71 | 72 | $process = $listener->makeProcess(null, 'queue', 1, 3, 0, 2, 3); 73 | $escape = '\\' === DIRECTORY_SEPARATOR ? '"' : '\''; 74 | 75 | $this->assertInstanceOf(Process::class, $process); 76 | $this->assertEquals(__DIR__, $process->getWorkingDirectory()); 77 | $this->assertEquals(3, $process->getTimeout()); 78 | $this->assertEquals($escape . PHP_BINARY . $escape . " {$escape}think{$escape} {$escape}queue:work{$escape} {$escape}--once{$escape} {$escape}--queue=queue{$escape} {$escape}--delay=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=0{$escape}", $process->getCommandLine()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/QueueTest.php: -------------------------------------------------------------------------------- 1 | queue = new Queue($this->app); 20 | } 21 | 22 | public function testDefaultConnectionCanBeResolved() 23 | { 24 | $sync = new Sync(); 25 | 26 | $this->app->shouldReceive('invokeClass')->once()->with('\think\queue\connector\Sync', [['driver' => 'sync']])->andReturn($sync); 27 | 28 | $config = m::mock(Config::class); 29 | 30 | $config->shouldReceive('get')->twice()->with('queue.connectors.sync', ['driver' => 'sync'])->andReturn(['driver' => 'sync']); 31 | $config->shouldReceive('get')->once()->with('queue.default', 'sync')->andReturn('sync'); 32 | 33 | $this->app->shouldReceive('get')->times(3)->with('config')->andReturn($config); 34 | 35 | $this->assertSame($sync, $this->queue->driver('sync')); 36 | $this->assertSame($sync, $this->queue->driver()); 37 | } 38 | 39 | public function testNotSupportDriver() 40 | { 41 | $config = m::mock(Config::class); 42 | 43 | $config->shouldReceive('get')->once()->with('queue.connectors.hello', ['driver' => 'sync'])->andReturn(['driver' => 'hello']); 44 | $this->app->shouldReceive('get')->once()->with('config')->andReturn($config); 45 | 46 | $this->expectException(InvalidArgumentException::class); 47 | $this->queue->driver('hello'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | app = m::mock(App::class)->makePartial(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/WorkerTest.php: -------------------------------------------------------------------------------- 1 | queue = m::mock(Queue::class); 38 | $this->handle = m::spy(Handle::class); 39 | $this->event = m::spy(Event::class); 40 | $this->cache = m::spy(Cache::class); 41 | } 42 | 43 | public function testJobCanBeFired() 44 | { 45 | 46 | $worker = $this->getWorker(['default' => [$job = new WorkerFakeJob]]); 47 | 48 | $this->event->shouldReceive('trigger')->with(m::type(JobProcessing::class))->once(); 49 | $this->event->shouldReceive('trigger')->with(m::type(JobProcessed::class))->once(); 50 | 51 | $worker->runNextJob('sync', 'default'); 52 | } 53 | 54 | public function testWorkerCanWorkUntilQueueIsEmpty() 55 | { 56 | $worker = $this->getWorker(['default' => [ 57 | $firstJob = new WorkerFakeJob, 58 | $secondJob = new WorkerFakeJob, 59 | ]]); 60 | 61 | $this->expectException(LoopBreakerException::class); 62 | 63 | $worker->daemon('sync', 'default'); 64 | 65 | $this->assertTrue($firstJob->fired); 66 | 67 | $this->assertTrue($secondJob->fired); 68 | 69 | $this->assertSame(0, $worker->stoppedWithStatus); 70 | 71 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobProcessing::class))->twice(); 72 | 73 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobProcessed::class))->twice(); 74 | } 75 | 76 | public function testJobCanBeFiredBasedOnPriority() 77 | { 78 | $worker = $this->getWorker([ 79 | 'high' => [ 80 | $highJob = new WorkerFakeJob, 81 | $secondHighJob = new WorkerFakeJob, 82 | ], 83 | 'low' => [$lowJob = new WorkerFakeJob], 84 | ]); 85 | 86 | $worker->runNextJob('sync', 'high,low'); 87 | 88 | $this->assertTrue($highJob->fired); 89 | $this->assertFalse($secondHighJob->fired); 90 | $this->assertFalse($lowJob->fired); 91 | 92 | $worker->runNextJob('sync', 'high,low'); 93 | $this->assertTrue($secondHighJob->fired); 94 | $this->assertFalse($lowJob->fired); 95 | 96 | $worker->runNextJob('sync', 'high,low'); 97 | $this->assertTrue($lowJob->fired); 98 | } 99 | 100 | public function testExceptionIsReportedIfConnectionThrowsExceptionOnJobPop() 101 | { 102 | $e = new RuntimeException(); 103 | 104 | $sync = m::mock(Sync::class); 105 | 106 | $sync->shouldReceive('pop')->andReturnUsing(function () use ($e) { 107 | throw $e; 108 | }); 109 | 110 | $this->queue->shouldReceive('driver')->with('sync')->andReturn($sync); 111 | 112 | $worker = new Worker($this->queue, $this->event, $this->handle); 113 | 114 | $worker->runNextJob('sync', 'default'); 115 | 116 | $this->handle->shouldHaveReceived('report')->with($e); 117 | } 118 | 119 | public function testWorkerSleepsWhenQueueIsEmpty() 120 | { 121 | $worker = $this->getWorker(['default' => []]); 122 | $worker->runNextJob('sync', 'default', 0, 5); 123 | $this->assertEquals(5, $worker->sleptFor); 124 | } 125 | 126 | public function testJobIsReleasedOnException() 127 | { 128 | $e = new RuntimeException; 129 | 130 | $job = new WorkerFakeJob(function () use ($e) { 131 | throw $e; 132 | }); 133 | 134 | $worker = $this->getWorker(['default' => [$job]]); 135 | $worker->runNextJob('sync', 'default', 10); 136 | 137 | $this->assertEquals(10, $job->releaseAfter); 138 | $this->assertFalse($job->deleted); 139 | $this->handle->shouldHaveReceived('report')->with($e); 140 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobExceptionOccurred::class))->once(); 141 | $this->event->shouldNotHaveReceived('trigger', [m::type(JobProcessed::class)]); 142 | } 143 | 144 | public function testJobIsNotReleasedIfItHasExceededMaxAttempts() 145 | { 146 | $e = new RuntimeException; 147 | 148 | $job = new WorkerFakeJob(function ($job) use ($e) { 149 | // In normal use this would be incremented by being popped off the queue 150 | $job->attempts++; 151 | 152 | throw $e; 153 | }); 154 | $job->attempts = 1; 155 | 156 | $worker = $this->getWorker(['default' => [$job]]); 157 | $worker->runNextJob('sync', 'default', 0, 3, 1); 158 | 159 | $this->assertNull($job->releaseAfter); 160 | $this->assertTrue($job->deleted); 161 | $this->assertEquals($e, $job->failedWith); 162 | $this->handle->shouldHaveReceived('report')->with($e); 163 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobExceptionOccurred::class))->once(); 164 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobFailed::class))->once(); 165 | $this->event->shouldNotHaveReceived('trigger', [m::type(JobProcessed::class)]); 166 | } 167 | 168 | public function testJobIsNotReleasedIfItHasExpired() 169 | { 170 | $e = new RuntimeException; 171 | 172 | $job = new WorkerFakeJob(function ($job) use ($e) { 173 | // In normal use this would be incremented by being popped off the queue 174 | $job->attempts++; 175 | 176 | throw $e; 177 | }); 178 | 179 | $job->timeoutAt = Carbon::now()->addSeconds(1)->getTimestamp(); 180 | 181 | $job->attempts = 0; 182 | 183 | Carbon::setTestNow( 184 | Carbon::now()->addSeconds(1) 185 | ); 186 | 187 | $worker = $this->getWorker(['default' => [$job]]); 188 | $worker->runNextJob('sync', 'default'); 189 | 190 | $this->assertNull($job->releaseAfter); 191 | $this->assertTrue($job->deleted); 192 | $this->assertEquals($e, $job->failedWith); 193 | $this->handle->shouldHaveReceived('report')->with($e); 194 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobExceptionOccurred::class))->once(); 195 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobFailed::class))->once(); 196 | $this->event->shouldNotHaveReceived('trigger', [m::type(JobProcessed::class)]); 197 | } 198 | 199 | public function testJobIsFailedIfItHasAlreadyExceededMaxAttempts() 200 | { 201 | $job = new WorkerFakeJob(function ($job) { 202 | $job->attempts++; 203 | }); 204 | 205 | $job->attempts = 2; 206 | 207 | $worker = $this->getWorker(['default' => [$job]]); 208 | $worker->runNextJob('sync', 'default', 0, 3, 1); 209 | 210 | $this->assertNull($job->releaseAfter); 211 | $this->assertTrue($job->deleted); 212 | $this->assertInstanceOf(MaxAttemptsExceededException::class, $job->failedWith); 213 | $this->handle->shouldHaveReceived('report')->with(m::type(MaxAttemptsExceededException::class)); 214 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobExceptionOccurred::class))->once(); 215 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobFailed::class))->once(); 216 | $this->event->shouldNotHaveReceived('trigger', [m::type(JobProcessed::class)]); 217 | } 218 | 219 | public function testJobIsFailedIfItHasAlreadyExpired() 220 | { 221 | $job = new WorkerFakeJob(function ($job) { 222 | $job->attempts++; 223 | }); 224 | 225 | $job->timeoutAt = Carbon::now()->addSeconds(2)->getTimestamp(); 226 | 227 | $job->attempts = 1; 228 | 229 | Carbon::setTestNow( 230 | Carbon::now()->addSeconds(3) 231 | ); 232 | 233 | $worker = $this->getWorker(['default' => [$job]]); 234 | $worker->runNextJob('sync', 'default'); 235 | 236 | $this->assertNull($job->releaseAfter); 237 | $this->assertTrue($job->deleted); 238 | $this->assertInstanceOf(MaxAttemptsExceededException::class, $job->failedWith); 239 | $this->handle->shouldHaveReceived('report')->with(m::type(MaxAttemptsExceededException::class)); 240 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobExceptionOccurred::class))->once(); 241 | $this->event->shouldHaveReceived('trigger')->with(m::type(JobFailed::class))->once(); 242 | $this->event->shouldNotHaveReceived('trigger', [m::type(JobProcessed::class)]); 243 | } 244 | 245 | public function testJobBasedMaxRetries() 246 | { 247 | $job = new WorkerFakeJob(function ($job) { 248 | $job->attempts++; 249 | }); 250 | 251 | $job->attempts = 2; 252 | 253 | $job->maxTries = 10; 254 | 255 | $worker = $this->getWorker(['default' => [$job]]); 256 | $worker->runNextJob('sync', 'default', 0, 3, 1); 257 | 258 | $this->assertFalse($job->deleted); 259 | $this->assertNull($job->failedWith); 260 | } 261 | 262 | protected function getWorker($jobs) 263 | { 264 | $sync = m::mock(Sync::class); 265 | 266 | $sync->shouldReceive('pop')->andReturnUsing(function ($queue) use (&$jobs) { 267 | return array_shift($jobs[$queue]); 268 | }); 269 | 270 | $this->queue->shouldReceive('driver')->with('sync')->andReturn($sync); 271 | 272 | return new Worker($this->queue, $this->event, $this->handle, $this->cache); 273 | } 274 | } 275 | 276 | class WorkerFakeConnector 277 | { 278 | public $jobs = []; 279 | 280 | public function __construct($jobs) 281 | { 282 | $this->jobs = $jobs; 283 | } 284 | 285 | public function pop($queue) 286 | { 287 | return array_shift($this->jobs[$queue]); 288 | } 289 | } 290 | 291 | class Worker extends \think\queue\Worker 292 | { 293 | public $sleptFor; 294 | 295 | public $stoppedWithStatus; 296 | 297 | public function sleep($seconds) 298 | { 299 | $this->sleptFor = $seconds; 300 | } 301 | 302 | public function stop($status = 0) 303 | { 304 | $this->stoppedWithStatus = $status; 305 | 306 | throw new LoopBreakerException; 307 | } 308 | 309 | protected function stopIfNecessary($job, $lastRestart, $memory) 310 | { 311 | if (is_null($job)) { 312 | $this->stop(); 313 | } else { 314 | parent::stopIfNecessary($job, $lastRestart, $memory); 315 | } 316 | } 317 | } 318 | 319 | class WorkerFakeJob 320 | { 321 | 322 | public $fired = false; 323 | public $callback; 324 | public $deleted = false; 325 | public $releaseAfter; 326 | public $released = false; 327 | public $maxTries; 328 | public $timeoutAt; 329 | public $attempts = 0; 330 | public $failedWith; 331 | public $failed = false; 332 | public $connectionName; 333 | 334 | public function __construct($callback = null) 335 | { 336 | $this->callback = $callback ?: function () { 337 | // 338 | }; 339 | } 340 | 341 | public function fire() 342 | { 343 | $this->fired = true; 344 | $this->callback->__invoke($this); 345 | } 346 | 347 | public function payload() 348 | { 349 | return []; 350 | } 351 | 352 | public function maxTries() 353 | { 354 | return $this->maxTries; 355 | } 356 | 357 | public function timeoutAt() 358 | { 359 | return $this->timeoutAt; 360 | } 361 | 362 | public function delete() 363 | { 364 | $this->deleted = true; 365 | } 366 | 367 | public function isDeleted() 368 | { 369 | return $this->deleted; 370 | } 371 | 372 | public function release($delay) 373 | { 374 | $this->released = true; 375 | 376 | $this->releaseAfter = $delay; 377 | } 378 | 379 | public function isReleased() 380 | { 381 | return $this->released; 382 | } 383 | 384 | public function attempts() 385 | { 386 | return $this->attempts; 387 | } 388 | 389 | public function markAsFailed() 390 | { 391 | $this->failed = true; 392 | } 393 | 394 | public function failed($e) 395 | { 396 | $this->markAsFailed(); 397 | 398 | $this->failedWith = $e; 399 | } 400 | 401 | public function hasFailed() 402 | { 403 | return $this->failed; 404 | } 405 | 406 | public function timeout() 407 | { 408 | return time() + 60; 409 | } 410 | 411 | public function getName() 412 | { 413 | return 'WorkerFakeJob'; 414 | } 415 | } 416 | 417 | class LoopBreakerException extends RuntimeException 418 | { 419 | // 420 | } 421 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |