├── .gitignore ├── src ├── TrackableJob.php ├── Dispatcher.php ├── EventManagers │ ├── EventManager.php │ ├── LegacyEventManager.php │ └── DefaultEventManager.php ├── LaravelJobStatusBusServiceProvider.php ├── LaravelJobStatusServiceProvider.php ├── Trackable.php ├── JobStatusUpdater.php └── JobStatus.php ├── config └── job-status.php ├── tests ├── _data │ ├── TestJob.php │ ├── TestJobWithoutTracking.php │ ├── TestJobWithException.php │ ├── TestJobWithFail.php │ └── TestJobWithDatabase.php ├── database │ └── migrations │ │ └── 2018_08_01_121717_create_jobs_table.php ├── Feature │ ├── TestCase.php │ ├── EventManagerTest.php │ ├── JobStatusUpdaterTest.php │ ├── DispatcherTest.php │ └── TrackableTest.php └── Unit │ └── JobStatusTest.php ├── .php_cs ├── LICENSE ├── phpunit.xml ├── database └── migrations │ └── 2017_05_01_000000_create_job_statuses_table.php ├── composer.json ├── .travis.yml ├── INSTALL.md ├── INSTALL_LUMEN.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .php_cs.cache 3 | composer.lock 4 | vendor/ -------------------------------------------------------------------------------- /src/TrackableJob.php: -------------------------------------------------------------------------------- 1 | \Imtigger\LaravelJobStatus\JobStatus::class, 5 | 'event_manager' => \Imtigger\LaravelJobStatus\EventManagers\DefaultEventManager::class, 6 | 'database_connection' => null 7 | ]; 8 | -------------------------------------------------------------------------------- /tests/_data/TestJob.php: -------------------------------------------------------------------------------- 1 | prepareStatus(); 22 | } 23 | 24 | public function handle() 25 | { 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Dispatcher.php: -------------------------------------------------------------------------------- 1 | updater = $updater; 15 | 16 | parent::__construct($container, $queueResolver); 17 | } 18 | 19 | public function dispatch($command) 20 | { 21 | $result = parent::dispatch($command); 22 | 23 | $this->updater->update($command, [ 24 | 'job_id' => $result, 25 | ]); 26 | 27 | return $result; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/_data/TestJobWithoutTracking.php: -------------------------------------------------------------------------------- 1 | shouldTrack = false; 22 | $this->prepareStatus(); 23 | } 24 | 25 | public function handle() 26 | { 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/_data/TestJobWithException.php: -------------------------------------------------------------------------------- 1 | prepareStatus(); 22 | } 23 | 24 | public function handle() 25 | { 26 | throw new \Exception('test-exception'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/_data/TestJobWithFail.php: -------------------------------------------------------------------------------- 1 | prepareStatus(); 22 | } 23 | 24 | public function handle() 25 | { 26 | $this->fail(new \Exception('test-exception')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@Symfony' => true, 6 | '@Symfony:risky' => true, 7 | '@PSR2' => true, 8 | 'array_syntax' => ['syntax' => 'short'], 9 | 'protected_to_private' => false, 10 | 'compact_nullable_typehint' => true, 11 | 'concat_space' => ['spacing' => 'one'], 12 | 'phpdoc_separation' => false, 13 | 'ordered_imports' => true, 14 | 'yoda_style' => null, 15 | ]) 16 | ->setRiskyAllowed(true) 17 | ->setFinder( 18 | PhpCsFixer\Finder::create() 19 | ->in([ 20 | __DIR__ . '/src', 21 | __DIR__ . '/tests', 22 | ]) 23 | ->notPath('#c3.php#') 24 | ->append([__FILE__]) 25 | ); 26 | -------------------------------------------------------------------------------- /tests/database/migrations/2018_08_01_121717_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('jobs'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Feature/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(realpath(__DIR__ . '/../../database/migrations')); 19 | $this->loadMigrationsFrom(realpath(__DIR__ . '/../database/migrations')); 20 | } 21 | 22 | /** 23 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 24 | */ 25 | protected function getPackageProviders($app) 26 | { 27 | return [ 28 | LaravelJobStatusServiceProvider::class, 29 | ConsoleServiceProvider::class, 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 imTigger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | tests 13 | 14 | 15 | 16 | 17 | src 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/EventManagers/EventManager.php: -------------------------------------------------------------------------------- 1 | updater = $updater; 29 | $this->entity = app(config('job-status.model')); 30 | } 31 | 32 | /** 33 | * @return JobStatusUpdater 34 | */ 35 | protected function getUpdater() 36 | { 37 | return $this->updater; 38 | } 39 | 40 | /** 41 | * @return JobStatus 42 | */ 43 | protected function getEntity() 44 | { 45 | return $this->entity; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /database/migrations/2017_05_01_000000_create_job_statuses_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 15 | $table->string('job_id')->index()->nullable(); 16 | $table->string('type')->index(); 17 | $table->string('queue')->index()->nullable(); 18 | $table->integer('attempts')->default(0); 19 | $table->integer('progress_now')->default(0); 20 | $table->integer('progress_max')->default(0); 21 | $table->string('status', 16)->default(\Imtigger\LaravelJobStatus\JobStatus::STATUS_QUEUED)->index(); 22 | $table->longText('input')->nullable(); 23 | $table->longText('output')->nullable(); 24 | $table->timestamps(); 25 | $table->timestamp('started_at')->nullable(); 26 | $table->timestamp('finished_at')->nullable(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | */ 33 | public function down() 34 | { 35 | Schema::drop('job_statuses'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/_data/TestJobWithDatabase.php: -------------------------------------------------------------------------------- 1 | data = $data; 28 | 29 | $this->prepareStatus(); 30 | } 31 | 32 | public function handle() 33 | { 34 | TestCase::assertThat( 35 | 'job_statuses', 36 | new HasInDatabase($this->getConnection(), [ 37 | 'id' => $this->getJobStatusId(), 38 | ] + $this->data) 39 | ); 40 | } 41 | 42 | protected function getConnection() 43 | { 44 | $database = app('db'); 45 | 46 | return $database->connection($database->getDefaultConnection()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imtigger/laravel-job-status", 3 | "description": "Laravel Job Status", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Tiger Fok", 8 | "email": "tiger@tiger-workshop.com" 9 | } 10 | ], 11 | "keywords": [ 12 | "laravel", 13 | "lumen", 14 | "job", 15 | "queue" 16 | ], 17 | "require": { 18 | "php": ">=7.1", 19 | "illuminate/contracts": ">=5.5", 20 | "illuminate/database": ">=5.5", 21 | "illuminate/queue": ">=5.5", 22 | "illuminate/support": ">=5.5", 23 | "nesbot/carbon": ">=1.21", 24 | "ext-json": "*" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": ">=5.7", 28 | "orchestra/testbench": ">=3.5", 29 | "orchestra/database": ">=3.5", 30 | "friendsofphp/php-cs-fixer": "^2.11" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Imtigger\\LaravelJobStatus\\LaravelJobStatusServiceProvider" 36 | ] 37 | } 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Imtigger\\LaravelJobStatus\\": "src" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Imtigger\\LaravelJobStatus\\Tests\\": "tests", 47 | "Imtigger\\LaravelJobStatus\\Tests\\Data\\": "tests/_data" 48 | } 49 | }, 50 | "scripts": { 51 | "php-cs-fixer": "vendor/bin/php-cs-fixer fix --config=.php_cs", 52 | "test": "composer php-cs-fixer && vendor/bin/phpunit" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Feature/EventManagerTest.php: -------------------------------------------------------------------------------- 1 | expectException(\Exception::class); 21 | 22 | config()->set('job-status.event_manager', $class); 23 | 24 | /** @var TestJob $job */ 25 | $job = new TestJobWithException(); 26 | 27 | app(Dispatcher::class)->dispatch($job); 28 | 29 | Artisan::call('queue:work', [ 30 | '--once' => 1, 31 | ]); 32 | 33 | $this->assertDatabaseHas('job_statuses', [ 34 | 'id' => $job->getJobStatusId(), 35 | 'status' => $status, 36 | ]); 37 | } 38 | 39 | public function managerProvider() 40 | { 41 | return [ 42 | [DefaultEventManager::class, JobStatus::STATUS_FAILED], 43 | [LegacyEventManager::class, JobStatus::STATUS_RETRYING], 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/EventManagers/LegacyEventManager.php: -------------------------------------------------------------------------------- 1 | getUpdater()->update($event, [ 16 | 'status' => $this->getEntity()::STATUS_EXECUTING, 17 | 'job_id' => $event->job->getJobId(), 18 | 'queue' => $event->job->getQueue(), 19 | 'started_at' => Carbon::now(), 20 | ]); 21 | } 22 | 23 | public function after(JobProcessed $event): void 24 | { 25 | $this->getUpdater()->update($event, [ 26 | 'status' => $this->getEntity()::STATUS_FINISHED, 27 | 'finished_at' => Carbon::now(), 28 | ]); 29 | } 30 | 31 | public function failing(JobFailed $event): void 32 | { 33 | $this->getUpdater()->update($event, [ 34 | 'status' => $this->getEntity()::STATUS_FAILED, 35 | 'finished_at' => Carbon::now(), 36 | ]); 37 | } 38 | 39 | public function exceptionOccurred(JobExceptionOccurred $event): void 40 | { 41 | $this->getUpdater()->update($event, [ 42 | 'status' => $this->getEntity()::STATUS_FAILED, 43 | 'finished_at' => Carbon::now(), 44 | 'output' => ['message' => $event->exception->getMessage()], 45 | ]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/LaravelJobStatusBusServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(Dispatcher::class, function ($app) { 25 | return new Dispatcher($app, function ($connection = null) use ($app) { 26 | return $app[QueueFactoryContract::class]->connection($connection); 27 | }, app(JobStatusUpdater::class)); 28 | }); 29 | $this->app->alias( 30 | Dispatcher::class, 31 | DispatcherContract::class 32 | ); 33 | $this->app->alias( 34 | Dispatcher::class, 35 | QueueingDispatcherContract::class 36 | ); 37 | } 38 | 39 | /** 40 | * Get the services provided by the provider. 41 | * 42 | * @return array 43 | */ 44 | public function provides() 45 | { 46 | return [ 47 | Dispatcher::class, 48 | DispatcherContract::class, 49 | QueueingDispatcherContract::class, 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | cache: 4 | directories: 5 | - $HOME/.composer/cache 6 | 7 | services: 8 | - mysql 9 | 10 | matrix: 11 | fast_finish: true 12 | include: 13 | - php: 7.1 14 | env: LARAVEL='5.5.*' TESTBENCH='3.5.*' COMPOSER_FLAGS='--prefer-lowest' 15 | - php: 7.1 16 | env: LARAVEL='5.6.*' TESTBENCH='3.6.*' 17 | - php: 7.1 18 | env: LARAVEL='5.7.*' TESTBENCH='3.7.*' 19 | - php: 7.1 20 | env: LARAVEL='5.8.*' TESTBENCH='3.8.*' 21 | - php: 7.2 22 | env: LARAVEL='5.5.*' TESTBENCH='3.5.*' 23 | - php: 7.2 24 | env: LARAVEL='5.6.*' TESTBENCH='3.6.*' 25 | - php: 7.2 26 | env: LARAVEL='5.7.*' TESTBENCH='3.7.*' 27 | - php: 7.2 28 | env: LARAVEL='5.8.*' TESTBENCH='3.8.*' 29 | - php: 7.3 30 | env: LARAVEL='5.5.*' TESTBENCH='3.5.*' 31 | - php: 7.3 32 | env: LARAVEL='5.6.*' TESTBENCH='3.6.*' 33 | - php: 7.3 34 | env: LARAVEL='5.7.*' TESTBENCH='3.7.*' 35 | - php: 7.3 36 | env: LARAVEL='5.8.*' TESTBENCH='3.8.*' 37 | 38 | before_install: 39 | - mysql -e 'CREATE DATABASE job_status;' 40 | - travis_retry composer self-update 41 | - COMPOSER_MEMORY_LIMIT=-1 travis_retry composer require --no-update --no-interaction "illuminate/support:${LARAVEL}" "illuminate/database:${LARAVEL}" "orchestra/testbench:${TESTBENCH}" 42 | 43 | install: 44 | - COMPOSER_MEMORY_LIMIT=-1 travis_retry composer update --no-interaction --no-plugins --no-suggest --prefer-source ${COMPOSER_FLAGS} 45 | 46 | script: 47 | - vendor/bin/php-cs-fixer fix --config=.php_cs -v --dry-run --stop-on-violation 48 | - vendor/bin/phpunit 49 | 50 | notifications: 51 | email: false -------------------------------------------------------------------------------- /src/EventManagers/DefaultEventManager.php: -------------------------------------------------------------------------------- 1 | getUpdater()->update($event, [ 16 | 'status' => $this->getEntity()::STATUS_EXECUTING, 17 | 'job_id' => $event->job->getJobId(), 18 | 'queue' => $event->job->getQueue(), 19 | 'started_at' => Carbon::now(), 20 | ]); 21 | } 22 | 23 | public function after(JobProcessed $event): void 24 | { 25 | if (!$event->job->hasFailed()) { 26 | $this->getUpdater()->update($event, [ 27 | 'status' => $this->getEntity()::STATUS_FINISHED, 28 | 'finished_at' => Carbon::now(), 29 | ]); 30 | } 31 | } 32 | 33 | public function failing(JobFailed $event): void 34 | { 35 | $this->getUpdater()->update($event, [ 36 | 'status' => ($event->job->attempts() !== $event->job->maxTries()) ? $this->getEntity()::STATUS_FAILED : $this->getEntity()::STATUS_RETRYING, 37 | 'finished_at' => Carbon::now(), 38 | ]); 39 | } 40 | 41 | public function exceptionOccurred(JobExceptionOccurred $event): void 42 | { 43 | $this->getUpdater()->update($event, [ 44 | 'status' => ($event->job->attempts() >= $event->job->maxTries()) ? $this->getEntity()::STATUS_FAILED : $this->getEntity()::STATUS_RETRYING, 45 | 'finished_at' => Carbon::now(), 46 | ]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/LaravelJobStatusServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/../database/migrations'); 18 | 19 | $this->mergeConfigFrom(__DIR__ . '/../config/job-status.php', 'job-status'); 20 | 21 | $this->publishes([ 22 | __DIR__ . '/../database/migrations/' => database_path('migrations'), 23 | ], 'migrations'); 24 | 25 | $this->publishes([ 26 | __DIR__ . '/../config/' => config_path(), 27 | ], 'config'); 28 | 29 | $this->bootListeners(); 30 | } 31 | 32 | private function bootListeners() 33 | { 34 | /** @var EventManager $eventManager */ 35 | $eventManager = app(config('job-status.event_manager')); 36 | 37 | // Add Event listeners 38 | app(QueueManager::class)->before(function (JobProcessing $event) use ($eventManager) { 39 | $eventManager->before($event); 40 | }); 41 | app(QueueManager::class)->after(function (JobProcessed $event) use ($eventManager) { 42 | $eventManager->after($event); 43 | }); 44 | app(QueueManager::class)->failing(function (JobFailed $event) use ($eventManager) { 45 | $eventManager->failing($event); 46 | }); 47 | app(QueueManager::class)->exceptionOccurred(function (JobExceptionOccurred $event) use ($eventManager) { 48 | $eventManager->exceptionOccurred($event); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation - Laravel 2 | 3 | This plugin can only be installed from [Composer](https://getcomposer.org/). 4 | 5 | Run the following command: 6 | ``` 7 | composer require imtigger/laravel-job-status 8 | ``` 9 | 10 | #### 1. Add Service Provider (Laravel < 5.5) 11 | 12 | Add the following to your `config/app.php`: 13 | 14 | ```php 15 | 'providers' => [ 16 | ... 17 | Imtigger\LaravelJobStatus\LaravelJobStatusServiceProvider::class, 18 | ] 19 | ``` 20 | 21 | #### 2. Publish migration and config (optional) 22 | 23 | ```bash 24 | php artisan vendor:publish --provider="Imtigger\LaravelJobStatus\LaravelJobStatusServiceProvider" 25 | ``` 26 | 27 | #### 3. Migrate Database 28 | 29 | ```bash 30 | php artisan migrate 31 | ``` 32 | 33 | #### 4. Use a custom JobStatus model (optional) 34 | 35 | To use your own JobStatus model you can change the model in `config/job-status.php` 36 | 37 | ```php 38 | return [ 39 | 'model' => App\JobStatus::class, 40 | ]; 41 | 42 | ``` 43 | 44 | #### 5. Improve job_id capture (optional) 45 | 46 | The first Laravel event that can be captured to insert the job_id into the JobStatus model is the `Queue::before` event. This means that the JobStatus won't have a job_id until it is being processed for the first time. 47 | 48 | If you would like the job_id to be stored immediately you can add the `LaravelJobStatusServiceProvider` to your `config/app.php`, which tells laravel to use our `Dispatcher`. 49 | ```php 50 | 'providers' => [ 51 | ... 52 | \Imtigger\LaravelJobStatus\LaravelJobStatusBusServiceProvider::class, 53 | ] 54 | ``` 55 | 56 | #### 6. Setup dedicated database connection (optional) 57 | 58 | Laravel support only one transaction per database connection. 59 | 60 | All changes made by JobStatus are also within transaction and therefore invisible to other connnections (e.g. progress page) 61 | 62 | If your job will update progress within transaction, copy your connection in `config/database.php` under another name like `'mysql-job-status'` with same config. 63 | 64 | Then set your connection to `'database_connection' => 'mysql-job-status'` in `config/job-status.php` 65 | -------------------------------------------------------------------------------- /src/Trackable.php: -------------------------------------------------------------------------------- 1 | update(['progress_max' => $value]); 16 | $this->progressMax = $value; 17 | } 18 | 19 | protected function setProgressNow($value, $every = 1) 20 | { 21 | if ($value % $every === 0 || $value === $this->progressMax) { 22 | $this->update(['progress_now' => $value]); 23 | } 24 | $this->progressNow = $value; 25 | } 26 | 27 | protected function incrementProgress($offset = 1, $every = 1) 28 | { 29 | $value = $this->progressNow + $offset; 30 | $this->setProgressNow($value, $every); 31 | } 32 | 33 | protected function setInput(array $value) 34 | { 35 | $this->update(['input' => $value]); 36 | } 37 | 38 | protected function setOutput(array $value) 39 | { 40 | $this->update(['output' => $value]); 41 | } 42 | 43 | protected function update(array $data) 44 | { 45 | /** @var JobStatusUpdater */ 46 | $updater = app(JobStatusUpdater::class); 47 | $updater->update($this, $data); 48 | } 49 | 50 | protected function prepareStatus(array $data = []) 51 | { 52 | if (!$this->shouldTrack) { 53 | return; 54 | } 55 | 56 | /** @var JobStatus */ 57 | $entityClass = app(config('job-status.model')); 58 | 59 | $data = array_merge(['type' => $this->getDisplayName()], $data); 60 | /** @var JobStatus */ 61 | $status = $entityClass::query()->create($data); 62 | 63 | $this->statusId = $status->getKey(); 64 | } 65 | 66 | protected function getDisplayName() 67 | { 68 | return method_exists($this, 'displayName') ? $this->displayName() : static::class; 69 | } 70 | 71 | public function getJobStatusId() 72 | { 73 | return $this->statusId; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Feature/JobStatusUpdaterTest.php: -------------------------------------------------------------------------------- 1 | assertDatabaseHas('job_statuses', [ 20 | 'id' => $job->getJobStatusId(), 21 | 'job_id' => null, 22 | ]); 23 | 24 | $updater->update($job, [ 25 | 'job_id' => 0, 26 | ]); 27 | 28 | $this->assertDatabaseHas('job_statuses', [ 29 | 'id' => $job->getJobStatusId(), 30 | 'job_id' => 0, 31 | ]); 32 | } 33 | 34 | public function testUpdateTrackableJob() 35 | { 36 | /** @var JobStatusUpdater $updater */ 37 | $updater = app(JobStatusUpdater::class); 38 | 39 | /** @var TestJob $job */ 40 | $job = new TestJob(); 41 | 42 | $this->assertDatabaseHas('job_statuses', [ 43 | 'id' => $job->getJobStatusId(), 44 | 'job_id' => null, 45 | ]); 46 | 47 | $updater->update($job, [ 48 | 'job_id' => 0, 49 | ]); 50 | 51 | $this->assertDatabaseHas('job_statuses', [ 52 | 'id' => $job->getJobStatusId(), 53 | 'job_id' => 0, 54 | ]); 55 | } 56 | 57 | public function testUpdateEvent() 58 | { 59 | $job = new TestJob(); 60 | 61 | $this->assertDatabaseHas('job_statuses', [ 62 | 'id' => $job->getJobStatusId(), 63 | 'job_id' => null, 64 | ]); 65 | 66 | app(Dispatcher::class)->dispatch($job); 67 | 68 | $this->assertDatabaseHas('job_statuses', [ 69 | 'id' => $job->getJobStatusId(), 70 | 'job_id' => 0, 71 | 'status' => 'finished', 72 | ]); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /INSTALL_LUMEN.md: -------------------------------------------------------------------------------- 1 | # Installation - Lumen 2 | 3 | This plugin can only be installed from [Composer](https://getcomposer.org/). 4 | 5 | Run the following command: 6 | ``` 7 | composer require imtigger/laravel-job-status 8 | ``` 9 | 10 | #### 0. Setup config_path polyfill if not 11 | 12 | Follow the [instruction by mabasic](https://gist.github.com/mabasic/21d13eab12462e596120) to add `config_path` helper function to your lumen installation. 13 | 14 | #### 1. Add Service Provider and QueueManager Binding 15 | 16 | Add the following to your `bootstrap/app.php`: 17 | 18 | ```php 19 | $app->bind(\Illuminate\Queue\QueueManager::class, function ($app) { 20 | return new \Illuminate\Queue\QueueManager($app); 21 | }); 22 | 23 | $app->register(Imtigger\LaravelJobStatus\LaravelJobStatusServiceProvider::class); 24 | ``` 25 | 26 | #### 2. Publish migration and config 27 | 28 | ```bash 29 | cp vendor/imtigger/laravel-job-status/database/migrations/2017_05_01_000000_create_job_statuses_table.php ./database/migrat 30 | ions/ 31 | cp vendor/imtigger/laravel-job-status/config/job-status.php ./config/ 32 | ``` 33 | 34 | #### 3. Migrate Database 35 | 36 | ```bash 37 | php artisan migrate 38 | ``` 39 | 40 | #### 4. Use a custom JobStatus model (optional) 41 | 42 | To use your own JobStatus model you can change the model in `config/job-status.php` 43 | 44 | ```php 45 | return [ 46 | 'model' => App\JobStatus::class, 47 | ]; 48 | 49 | ``` 50 | 51 | #### 5. Improve job_id capture (optional) 52 | 53 | The first laravel event that can be captured to insert the job_id into the JobStatus model is the Queue::before event. This means that the JobStatus won't have a job_id until it is being processed for the first time. 54 | 55 | If you would like the job_id to be stored immediately you can add the `LaravelJobStatusServiceProvider` to your `bootstrap/app.php`, which tells laravel to use our `Dispatcher`. 56 | ```php 57 | $app->register(Imtigger\LaravelJobStatus\LaravelJobStatusBusServiceProvider::class); 58 | ``` 59 | 60 | #### 6. Setup dedicated database connection (optional) 61 | 62 | Laravel support only one transcation per database connection. 63 | 64 | All changes made by JobStatus are also within transaction and therefore invisible to other connnections (e.g. progress page) 65 | 66 | If your job will update progress within transaction, copy your connection in `config/database.php` under another name like `'mysql-job-status'` with same config. 67 | 68 | Then set your connection to `'database_connection' => 'mysql-job-status'` in `config/job-status.php` 69 | -------------------------------------------------------------------------------- /tests/Feature/DispatcherTest.php: -------------------------------------------------------------------------------- 1 | set('queue.default', 'database'); 23 | } 24 | 25 | public function testDefaultDispatcher() 26 | { 27 | $job = new TestJob(); 28 | 29 | $this->assertDatabaseHas('job_statuses', [ 30 | 'id' => $job->getJobStatusId(), 31 | 'job_id' => null, 32 | ]); 33 | 34 | app(Dispatcher::class)->dispatch($job); 35 | 36 | $this->assertDatabaseHas('job_statuses', [ 37 | 'id' => $job->getJobStatusId(), 38 | 'job_id' => null, 39 | ]); 40 | } 41 | 42 | public function testCustomDispatcher() 43 | { 44 | $job = new TestJob(); 45 | 46 | $this->assertDatabaseHas('job_statuses', [ 47 | 'id' => $job->getJobStatusId(), 48 | 'job_id' => null, 49 | ]); 50 | 51 | app(\Illuminate\Contracts\Bus\Dispatcher::class)->dispatch($job); 52 | 53 | $this->assertDatabaseHas('job_statuses', [ 54 | 'id' => $job->getJobStatusId(), 55 | 'job_id' => 1, 56 | ]); 57 | } 58 | 59 | public function testCustomDispatcherChained() 60 | { 61 | $job = new TestJobWithDatabase([]); 62 | 63 | $this->assertDatabaseHas('job_statuses', [ 64 | 'id' => $job->getJobStatusId(), 65 | 'job_id' => null, 66 | ]); 67 | 68 | TestJob::withChain([ 69 | $job, 70 | ])->dispatch(); 71 | 72 | Artisan::call('queue:work', [ 73 | '--once' => 1, 74 | ]); 75 | 76 | $this->assertDatabaseHas('job_statuses', [ 77 | 'id' => $job->getJobStatusId(), 78 | 'job_id' => 2, 79 | ]); 80 | } 81 | 82 | public function testSetup() 83 | { 84 | $this->assertInstanceOf(\Imtigger\LaravelJobStatus\Dispatcher::class, app(\Illuminate\Contracts\Bus\Dispatcher::class)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Unit/JobStatusTest.php: -------------------------------------------------------------------------------- 1 | input = ['first' => 'attr', 'second' => 'attribute']; 14 | 15 | $this->assertEquals($jobStatus->input, ['first' => 'attr', 'second' => 'attribute']); 16 | } 17 | 18 | public function testGetAndSetsOutputAttribute() 19 | { 20 | $jobStatus = new JobStatus(); 21 | $jobStatus->output = ['first' => 'attr', 'second' => 'attribute']; 22 | 23 | $this->assertEquals($jobStatus->output, ['first' => 'attr', 'second' => 'attribute']); 24 | } 25 | 26 | public function testProgressPercentageAttributeProgressMax0() 27 | { 28 | $jobStatus = new JobStatus(); 29 | $jobStatus->progress_max = 0; 30 | 31 | $this->assertEquals(0, $jobStatus->progressPercentage); 32 | } 33 | 34 | public function testProgressPercentageAttributeProgressMax100() 35 | { 36 | $jobStatus = new JobStatus(); 37 | $jobStatus->progress_max = 100; 38 | $jobStatus->progress_now = 49.6; 39 | 40 | $this->assertEquals(50, $jobStatus->progressPercentage); 41 | } 42 | 43 | public function testJobStatusIsEndedWhenFailed() 44 | { 45 | $jobStatus = new JobStatus(); 46 | $jobStatus->status = 'failed'; 47 | 48 | $this->assertTrue($jobStatus->isEnded); 49 | } 50 | 51 | public function testJobStatusIsEndedWhenFinished() 52 | { 53 | $jobStatus = new JobStatus(); 54 | $jobStatus->status = 'finished'; 55 | 56 | $this->assertTrue($jobStatus->isEnded); 57 | } 58 | 59 | public function testJobStatusIsFinished() 60 | { 61 | $jobStatus = new JobStatus(); 62 | $jobStatus->status = 'finished'; 63 | 64 | $this->assertTrue($jobStatus->isFinished); 65 | } 66 | 67 | public function testJobStatusIsFailed() 68 | { 69 | $jobStatus = new JobStatus(); 70 | $jobStatus->status = 'failed'; 71 | 72 | $this->assertTrue($jobStatus->isEnded); 73 | } 74 | 75 | public function testJobStatusIsExecuting() 76 | { 77 | $jobStatus = new JobStatus(); 78 | $jobStatus->status = 'executing'; 79 | 80 | $this->assertTrue($jobStatus->isExecuting); 81 | } 82 | 83 | public function testJobStatusIsRetrying() 84 | { 85 | $jobStatus = new JobStatus(); 86 | $jobStatus->status = 'retrying'; 87 | 88 | $this->assertTrue($jobStatus->isRetrying); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Feature/TrackableTest.php: -------------------------------------------------------------------------------- 1 | 'executing', 29 | ]); 30 | 31 | $this->assertDatabaseHas('job_statuses', [ 32 | 'id' => $job->getJobStatusId(), 33 | 'status' => 'queued', 34 | ]); 35 | 36 | app(Dispatcher::class)->dispatch($job); 37 | 38 | Artisan::call('queue:work', [ 39 | '--once' => 1, 40 | ]); 41 | 42 | $this->assertDatabaseHas('job_statuses', [ 43 | 'id' => $job->getJobStatusId(), 44 | 'status' => 'finished', 45 | ]); 46 | } 47 | 48 | public function testStatusFailedWithException() 49 | { 50 | $this->expectException(\Exception::class); 51 | 52 | /** @var TestJob $job */ 53 | $job = new TestJobWithException(); 54 | 55 | app(Dispatcher::class)->dispatch($job); 56 | 57 | Artisan::call('queue:work', [ 58 | '--once' => 1, 59 | ]); 60 | 61 | $this->assertDatabaseHas('job_statuses', [ 62 | 'id' => $job->getJobStatusId(), 63 | 'status' => 'failed', 64 | ]); 65 | } 66 | 67 | public function testStatusFailedWithFail() 68 | { 69 | /** @var TestJob $job */ 70 | $job = new TestJobWithFail(); 71 | 72 | app(Dispatcher::class)->dispatch($job); 73 | 74 | Artisan::call('queue:work', [ 75 | '--once' => 1, 76 | ]); 77 | 78 | $this->assertDatabaseHas('job_statuses', [ 79 | 'id' => $job->getJobStatusId(), 80 | 'status' => 'failed', 81 | ]); 82 | } 83 | 84 | public function testTrackingDisabled() 85 | { 86 | $job = new TestJobWithoutTracking(); 87 | 88 | $this->assertNull($job->getJobStatusId()); 89 | 90 | $this->assertEquals(0, JobStatus::query()->count()); 91 | 92 | app(Dispatcher::class)->dispatch($job); 93 | 94 | $this->assertEquals(0, JobStatus::query()->count()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/JobStatusUpdater.php: -------------------------------------------------------------------------------- 1 | isEvent($job)) { 16 | $this->updateEvent($job, $data); 17 | } 18 | 19 | $this->updateJob($job, $data); 20 | } 21 | 22 | /** 23 | * @param JobProcessing|JobProcessed|JobFailed|JobExceptionOccurred $event 24 | */ 25 | protected function updateEvent($event, array $data) 26 | { 27 | $job = $this->parseJob($event); 28 | $jobStatus = $this->getJobStatus($job); 29 | 30 | if (!$jobStatus) { 31 | return; 32 | } 33 | 34 | try { 35 | $data['attempts'] = $event->job->attempts(); 36 | } catch (\Throwable $e) { 37 | try { 38 | $data['attempts'] = $job->attempts(); 39 | } catch (\Throwable $e) { 40 | Log::error($e->getMessage()); 41 | } 42 | } 43 | 44 | if ($jobStatus->isFailed 45 | && isset($data['status']) 46 | && $data['status'] === $jobStatus::STATUS_FINISHED 47 | ) { 48 | unset($data['status']); 49 | } 50 | 51 | $jobStatus->update($data); 52 | } 53 | 54 | protected function updateJob($job, array $data) 55 | { 56 | if ($jobStatus = $this->getJobStatus($job)) { 57 | $jobStatus->update($data); 58 | } 59 | } 60 | 61 | /** 62 | * @param JobProcessing|JobProcessed|JobFailed|JobExceptionOccurred $event 63 | * @return mixed|null 64 | */ 65 | protected function parseJob($event) 66 | { 67 | try { 68 | $payload = $event->job->payload(); 69 | 70 | return unserialize($payload['data']['command']); 71 | } catch (\Throwable $e) { 72 | Log::error($e->getMessage()); 73 | 74 | return null; 75 | } 76 | } 77 | 78 | protected function getJobStatusId($job) 79 | { 80 | try { 81 | if ($job instanceof TrackableJob || method_exists($job, 'getJobStatusId')) { 82 | return $job->getJobStatusId(); 83 | } 84 | } catch (\Throwable $e) { 85 | Log::error($e->getMessage()); 86 | 87 | return null; 88 | } 89 | 90 | return null; 91 | } 92 | 93 | protected function getJobStatus($job) 94 | { 95 | if ($id = $this->getJobStatusId($job)) { 96 | /** @var JobStatus $entityClass */ 97 | $entityClass = app(config('job-status.model')); 98 | 99 | return $entityClass::on(config('job-status.database_connection'))->whereKey($id)->first(); 100 | } 101 | 102 | return null; 103 | } 104 | 105 | protected function isEvent($job) 106 | { 107 | return $job instanceof JobProcessing 108 | || $job instanceof JobProcessed 109 | || $job instanceof JobFailed 110 | || $job instanceof JobExceptionOccurred; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/JobStatus.php: -------------------------------------------------------------------------------- 1 | 'array', 60 | 'output' => 'array', 61 | ]; 62 | 63 | /* Accessor */ 64 | public function getProgressPercentageAttribute() 65 | { 66 | return $this->progress_max !== 0 ? round(100 * $this->progress_now / $this->progress_max) : 0; 67 | } 68 | 69 | public function getIsEndedAttribute() 70 | { 71 | return \in_array($this->status, [self::STATUS_FAILED, self::STATUS_FINISHED], true); 72 | } 73 | 74 | public function getIsFinishedAttribute() 75 | { 76 | return $this->status === self::STATUS_FINISHED; 77 | } 78 | 79 | public function getIsFailedAttribute() 80 | { 81 | return $this->status === self::STATUS_FAILED; 82 | } 83 | 84 | public function getIsExecutingAttribute() 85 | { 86 | return $this->status === self::STATUS_EXECUTING; 87 | } 88 | 89 | public function getIsQueuedAttribute() 90 | { 91 | return $this->status === self::STATUS_QUEUED; 92 | } 93 | 94 | public function getIsRetryingAttribute() 95 | { 96 | return $this->status === self::STATUS_RETRYING; 97 | } 98 | 99 | public static function getAllowedStatuses() 100 | { 101 | return [ 102 | self::STATUS_QUEUED, 103 | self::STATUS_EXECUTING, 104 | self::STATUS_FINISHED, 105 | self::STATUS_FAILED, 106 | self::STATUS_RETRYING, 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Job Status 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/imTigger/laravel-job-status/v/stable)](https://packagist.org/packages/imTigger/laravel-job-status) 4 | [![Total Downloads](https://poser.pugx.org/imTigger/laravel-job-status/downloads)](https://packagist.org/packages/imTigger/laravel-job-status) 5 | [![Build Status](https://travis-ci.org/imTigger/laravel-job-status.svg?branch=master)](https://travis-ci.org/imTigger/laravel-job-status) 6 | [![License](https://poser.pugx.org/imTigger/laravel-job-status/license)](https://packagist.org/packages/imTigger/laravel-job-status) 7 | 8 | 9 | Laravel package to add ability to track `Job` progress, status and result dispatched to `Queue`. 10 | 11 | - Queue name, attempts, status and created/updated/started/finished timestamp. 12 | - Progress update, with arbitrary current/max value and percentage auto calculated 13 | - Handles failed job with exception message 14 | - Custom input/output 15 | - Native Eloquent model `JobStatus` 16 | - Support all drivers included in Laravel (null/sync/database/beanstalkd/redis/sqs) 17 | 18 | - This package intentionally do not provide any UI for displaying Job progress. 19 | 20 | If you have such need, please refer to [laravel-job-status-progress-view](https://github.com/imTigger/laravel-job-status-progress-view) 21 | 22 | or make your own implementation using `JobStatus` model 23 | 24 | ## Requirements 25 | 26 | - PHP >= 7.1 27 | - Laravel/Lumen >= 5.5 28 | 29 | ## Installation 30 | 31 | [Installation for Laravel](INSTALL.md) 32 | 33 | [Installation for Lumen](INSTALL_LUMEN.md) 34 | 35 | ### Usage 36 | 37 | In your `Job`, use `Trackable` trait and call `$this->prepareStatus()` in constructor. 38 | 39 | ```php 40 | prepareStatus(); 56 | $this->params = $params; // Optional 57 | $this->setInput($this->params); // Optional 58 | } 59 | 60 | /** 61 | * Execute the job. 62 | * 63 | * @return void 64 | */ 65 | public function handle() 66 | { 67 | $max = mt_rand(5, 30); 68 | $this->setProgressMax($max); 69 | 70 | for ($i = 0; $i <= $max; $i += 1) { 71 | sleep(1); // Some Long Operations 72 | $this->setProgressNow($i); 73 | } 74 | 75 | $this->setOutput(['total' => $max, 'other' => 'parameter']); 76 | } 77 | } 78 | 79 | ``` 80 | 81 | In your Job dispatcher, call `$job->getJobStatusId()` to get `$jobStatusId`: 82 | 83 | ```php 84 | dispatch($job); 92 | 93 | $jobStatusId = $job->getJobStatusId(); 94 | } 95 | } 96 | ``` 97 | 98 | `$jobStatusId` can be used elsewhere to retrieve job status, progress and output. 99 | 100 | ```php 101 | getJobStatusId() 107 | 108 | Laravel provide many ways to dispatch Jobs. Not all methods return your Job object, for example: 109 | 110 | ```php 111 | getJobStatusId();` 113 | ``` 114 | 115 | If you really need to dispatch job in this way, workarounds needed: Create your own key 116 | 117 | 1. Create migration adding extra key to job_statuses table. 118 | 119 | 2. In your job, generate your own unique key and pass into `prepareStatus();`, `$this->prepareStatus(['key' => $params['key']]);` 120 | 121 | 3. Find JobStatus another way: `$jobStatus = JobStatus::whereKey($key)->firstOrFail();` 122 | 123 | #### Status not updating until transaction commited 124 | 125 | On version >= 1.1, dedicated database connection support is added. 126 | 127 | Therefore JobStatus updates can be saved instantly even within your application transaction. 128 | 129 | Read setup step 6 for instructions. 130 | 131 | 132 | ## Documentations 133 | 134 | ```php 135 | prepareStatus(); // Must be called in constructor before any other methods 138 | $this->setProgressMax(int $v); // Update the max number of progress 139 | $this->setProgressNow(int $v); // Update the current number progress 140 | $this->setProgressNow(int $v, int $every); // Update the current number progress, write to database only when $v % $every == 0 141 | $this->incrementProgress(int $offset) // Increase current number progress by $offset 142 | $this->incrementProgress(int $offset, int $every) // Increase current number progress by $offset, write to database only when $v % $every == 0 143 | $this->setInput(array $v); // Store input into database 144 | $this->setOutput(array $v); // Store output into database (Typically the run result) 145 | 146 | // Job public methods (Call from your Job dispatcher) 147 | $job->getJobStatusId(); // Return the primary key of JobStatus (To retrieve status later) 148 | 149 | // JobStatus object fields 150 | var_dump($jobStatus->job_id); // String (Result varies with driver, see note) 151 | var_dump($jobStatus->type); // String 152 | var_dump($jobStatus->queue); // String 153 | var_dump($jobStatus->status); // String [queued|executing|finished|retrying|failed] 154 | var_dump($jobStatus->attempts); // Integer 155 | var_dump($jobStatus->progress_now); // Integer 156 | var_dump($jobStatus->progress_max); // Integer 157 | var_dump($jobStatus->input); // Array 158 | var_dump($jobStatus->output); // Array, ['message' => $exception->getMessage()] if job failed 159 | var_dump($jobStatus->created_at); // Carbon object 160 | var_dump($jobStatus->updated_at); // Carbon object 161 | var_dump($jobStatus->started_at); // Carbon object 162 | var_dump($jobStatus->finished_at); // Carbon object 163 | 164 | // JobStatus generated fields 165 | var_dump($jobStatus->progress_percentage); // Double [0-100], useful for displaying progress bar 166 | var_dump($jobStatus->is_ended); // Boolean, true if status == finished || status == failed 167 | var_dump($jobStatus->is_executing); // Boolean, true if status == executing 168 | var_dump($jobStatus->is_failed); // Boolean, true if status == failed 169 | var_dump($jobStatus->is_finished); // Boolean, true if status == finished 170 | var_dump($jobStatus->is_queued); // Boolean, true if status == queued 171 | var_dump($jobStatus->is_retrying); // Boolean, true if status == retrying 172 | ``` 173 | 174 | # Note 175 | 176 | `$jobStatus->job_id` result varys with driver 177 | 178 | | Driver | job_id 179 | | ---------- | -------- 180 | | null | NULL (Job not run at all!) 181 | | sync | empty string 182 | | database | integer 183 | | beanstalkd | integer 184 | | redis | string(32) 185 | | sqs | GUID 186 | --------------------------------------------------------------------------------