├── .editorconfig ├── .github └── workflows │ ├── build-laravel.yml │ ├── build.yml │ └── try-installation.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docs ├── APIs.md ├── GUI.md ├── QueueFacade.md └── SafeDispatcher.png ├── phpunit.xml ├── src ├── Authorizations │ ├── BaseExternalCheck.php │ ├── FailedToDispatchRetryCheck.php │ └── FailedToDispatchViewCheck.php ├── Bus │ ├── SafeDispatcher.php │ ├── SafePendingClosureDispatch.php │ └── SafePendingDispatch.php ├── Configs │ └── safe-dispatcher.php ├── Database │ ├── Factories │ │ └── FailedToDispatchJobFactory.php │ └── Migrations │ │ └── 2023_02_04_104010_create_failed_to_dispatch_jobs.php ├── Functions │ └── safe-dispatcher-functions.php ├── Http │ ├── Controllers │ │ └── FailedToDispatchJobController.php │ ├── Requests │ │ ├── FailedToDispatchJobIndexRequest.php │ │ ├── FailedToDispatchJobRetryRequest.php │ │ └── FailedToDispatchJobViewRequest.php │ └── Resources │ │ └── Models │ │ └── FailedToDispatchJobResource.php ├── Models │ └── FailedToDispatchJob.php ├── Routes │ └── safe-dispatcher-routes.php ├── SafeDispatcherServiceProvider.php ├── Services │ ├── FailDispatcherService.php │ ├── RedispatchOption.php │ └── SafeQueue.php └── Traits │ └── SafeDispatchable.php └── tests ├── Feature └── FailedToDispatchJobControllerTest.php ├── Integration └── RedisQueueTest.php ├── TestCase.php └── Unit ├── BaseExternalCheckTest.php ├── SafeDispatcherTest.php ├── SafePendingDispatchTest.php └── SafeQueueTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [composer.json] 18 | indent_size = 4 19 | indent_style = space -------------------------------------------------------------------------------- /.github/workflows/build-laravel.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test (Laravel 9, 10) 2 | env: 3 | TESTING_ENV: ${{ secrets.TESTING_ENV }} 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - 'main' 9 | types: [ opened, synchronize, reopened, ready_for_review ] 10 | push: 11 | branches: 12 | - 'main' 13 | 14 | jobs: 15 | build_laravel: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | version: [ 20 | '9', 21 | '10' 22 | ] 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | if: success() 27 | 28 | - name: Setup PHP with coverage driver 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: 8.1 32 | coverage: pcov 33 | 34 | - name: Start Redis 35 | uses: supercharge/redis-github-action@1.4.0 36 | with: 37 | redis-version: 6 38 | 39 | - name: Setup 40 | if: success() 41 | run: | 42 | sudo service mysql start 43 | php -v 44 | mysql -uroot -proot -e "CREATE DATABASE priority_queue;" 45 | composer install --no-interaction 46 | echo "$TESTING_ENV" > .env.testing 47 | 48 | - name: Laravel 9 composition 49 | if: matrix.version == '9' 50 | run: | 51 | composer require \ 52 | "laravel/framework" "^9" \ 53 | "orchestra/testbench" "^7" \ 54 | --with-all-dependencies 55 | 56 | - name: Laravel 10 composition 57 | if: matrix.version == '10' 58 | run: | 59 | composer require \ 60 | "laravel/framework" "^10" \ 61 | "orchestra/testbench" "^8" \ 62 | --with-all-dependencies 63 | 64 | - name: PHPUnit tests 65 | if: success() && github.event.pull_request.draft == false 66 | run: | 67 | composer test 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | env: 3 | SSH_CONFIG: ${{ secrets.TESTING_ENV }} 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - 'main' 9 | types: [ opened, synchronize, reopened, ready_for_review ] 10 | push: 11 | branches: 12 | - 'main' 13 | 14 | jobs: 15 | build_php: 16 | strategy: 17 | matrix: 18 | version: [ '8.1', '8.2' ] 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | if: success() 23 | 24 | - name: Setup PHP with coverage driver 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.version }} 28 | coverage: pcov 29 | extensions: phpredis 30 | 31 | - name: Start Redis 32 | uses: supercharge/redis-github-action@1.4.0 33 | with: 34 | redis-version: 6 35 | 36 | - name: Setup 37 | if: success() 38 | run: | 39 | php -v 40 | sudo service mysql start 41 | composer install --no-interaction 42 | echo "$TESTING_ENV" > .env.testing 43 | 44 | - name: PHPUnit tests with coverage 45 | if: success() && github.event.pull_request.draft == false 46 | run: | 47 | composer test-coverage 48 | 49 | - name: upload coverage to codecov.io 50 | if: success() && matrix.version == '8.1' 51 | uses: codecov/codecov-action@v1 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | file: ./coverage.xml 55 | -------------------------------------------------------------------------------- /.github/workflows/try-installation.yml: -------------------------------------------------------------------------------- 1 | name: Try Install Package (Laravel 9 & 10) 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | types: [ opened, synchronize, reopened, ready_for_review ] 8 | push: 9 | branches: 10 | - 'main' 11 | 12 | jobs: 13 | try_installation: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | version: [ '^9.0', '^10.0' ] 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Setup PHP with coverage driver 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: 8.1 24 | coverage: pcov 25 | 26 | - name: Setup and install package on Laravel 27 | if: success() 28 | run: | 29 | sudo service mysql start 30 | mysql -uroot -proot -e "CREATE DATABASE priority_queue;" 31 | composer create-project laravel/laravel:${{ matrix.version }} laravel 32 | cd laravel 33 | composer require shipsaas/safe-dispatcher 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | node_modules/ 3 | npm-debug.log 4 | yarn-error.log 5 | 6 | .env 7 | Homestead.yaml 8 | Homestead.json 9 | /.vagrant 10 | .phpunit.result.cache 11 | 12 | .idea/ 13 | .php-cs-fixer.cache 14 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in([ 8 | __DIR__ . '/src/', 9 | __DIR__ . '/tests/', 10 | ]); 11 | 12 | $config = new Config(); 13 | 14 | return $config->setFinder($finder) 15 | ->setRules([ 16 | '@PSR12' => true, 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'combine_consecutive_unsets' => true, 19 | 'multiline_whitespace_before_semicolons' => true, 20 | 'single_quote' => true, 21 | 'binary_operator_spaces' => ['default' => 'single_space'], 22 | 'blank_line_before_statement' => ['statements' => ['return']], 23 | 'braces' => [ 24 | 'allow_single_line_closure' => true, 25 | 'position_after_anonymous_constructs' => 'same', 26 | 'position_after_control_structures' => 'same', 27 | 'position_after_functions_and_oop_constructs' => 'next', 28 | ], 29 | 'combine_consecutive_issets' => true, 30 | 'class_attributes_separation' => ['elements' => ['method' => 'one']], 31 | 'concat_space' => ['spacing' => 'one'], 32 | 'include' => true, 33 | 'no_extra_blank_lines' => [ 34 | 'tokens' => [ 35 | 'curly_brace_block', 36 | 'extra', 37 | 'parenthesis_brace_block', 38 | 'square_brace_block', 39 | 'throw', 40 | 'use', 41 | ], 42 | ], 43 | 'no_multiline_whitespace_around_double_arrow' => true, 44 | 'no_spaces_around_offset' => true, 45 | 'no_unused_imports' => true, 46 | 'no_whitespace_before_comma_in_array' => true, 47 | 'object_operator_without_whitespace' => true, 48 | 'php_unit_fqcn_annotation' => true, 49 | 'phpdoc_no_package' => true, 50 | 'phpdoc_scalar' => true, 51 | 'phpdoc_single_line_var_spacing' => true, 52 | 'protected_to_private' => true, 53 | 'return_assignment' => true, 54 | 'no_useless_return' => true, 55 | 'simplified_null_return' => true, 56 | 'single_line_after_imports' => true, 57 | 'single_line_comment_style' => ['comment_types' => ['hash']], 58 | 'single_class_element_per_statement' => true, 59 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 60 | 'trim_array_spaces' => true, 61 | 'unary_operator_spaces' => true, 62 | 'whitespace_after_comma_in_array' => true, 63 | 'no_null_property_initialization' => true, 64 | 65 | 'function_typehint_space' => true, 66 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 67 | 'no_empty_statement' => true, 68 | 'no_leading_namespace_whitespace' => true, 69 | 'return_type_declaration' => ['space_before' => 'none'], 70 | 71 | 'method_chaining_indentation' => true, 72 | 'align_multiline_comment' => ['comment_type' => 'all_multiline'], 73 | 'no_superfluous_phpdoc_tags' => [ 74 | 'allow_mixed' => false, 75 | 'remove_inheritdoc' => false, 76 | 'allow_unused_params' => false, 77 | ], 78 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 79 | 'phpdoc_trim' => true, 80 | 'no_empty_phpdoc' => true, 81 | 'clean_namespace' => true, 82 | 'array_indentation' => true, 83 | 'elseif' => true, 84 | 'phpdoc_order' => true, 85 | 'global_namespace_import' => [ 86 | 'import_classes' => true, 87 | 'import_constants' => false, 88 | 'import_functions' => false, 89 | ], 90 | 'fully_qualified_strict_types' => true, 91 | 'no_leading_import_slash' => true, 92 | ]) 93 | ->setLineEnding("\n"); 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog - Safe Dispatcher 2 | 3 | ## v1.2.0 4 | - `SafeQueue` to cover `Queue` facade. 5 | 6 | ## v1.1.0 7 | - Supports Laravel 10 8 | 9 | ## v1.0.0 10 | 11 | - Initial Release 12 | - All proposed features are implemented & fully tested (under unit & integration testing) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Seth Phat / ShipSaaS 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Safe Dispatcher for Queues 2 | 3 | [![Latest Version](http://poser.pugx.org/shipsaas/safe-dispatcher/v)](https://packagist.org/packages/shipsaas/safe-dispatcher) 4 | [![Total Downloads](http://poser.pugx.org/shipsaas/safe-dispatcher/downloads)](https://packagist.org/packages/shipsaas/safe-dispatcher) 5 | [![codecov](https://codecov.io/gh/shipsaas/safe-dispatcher/branch/main/graph/badge.svg?token=FLVU412CUI)](https://codecov.io/gh/shipsaas/safe-dispatcher) 6 | [![Build & Test](https://github.com/shipsaas/safe-dispatcher/actions/workflows/build.yml/badge.svg)](https://github.com/shipsaas/safe-dispatcher/actions/workflows/build.yml) 7 | [![Build & Test (Laravel 9, 10)](https://github.com/shipsaas/safe-dispatcher/actions/workflows/build-laravel.yml/badge.svg)](https://github.com/shipsaas/safe-dispatcher/actions/workflows/build-laravel.yml) 8 | [![Try Install Package (Laravel 9 & 10)](https://github.com/shipsaas/safe-dispatcher/actions/workflows/try-installation.yml/badge.svg)](https://github.com/shipsaas/safe-dispatcher/actions/workflows/try-installation.yml) 9 | 10 | For Laravel, it has the Queues feature, all cool and easy to use, right? 11 | 12 | But what if it **fails to dispatch a job**? Then you have no idea for: 13 | 14 | - What was the data inside the msg? 15 | - What was the error? Traces? 16 | - How to resend the Queue msg with minimal effort? 17 | 18 | Then it will cost you a lot of time to check the log, sentry issues, create retry command,... Awful, IKR? 19 | 20 | Worries no more, SafeDispatcher got your back. Check out how it works below. 21 | 22 | Documentation: 23 | 24 | - This README 25 | - [APIs](./docs/APIs.md) 26 | - [GUI](./docs/GUI.md) 27 | 28 | ## How SafeDispatcher works? 29 | 30 | ![How does Laravel SafeDispatcher works?](./docs/SafeDispatcher.png) 31 | 32 | SafeDispatcher will: 33 | 34 | - Store the failed to dispatch msgs. 35 | - Retry them on demand. 36 | - You can even change the connection driver or the name on retry. 37 | - Would really come in handy when you have a `SQSException` (size > 256kb), then you can resend using redis/database driver. 38 | - Ensure that your processing/flow is still working properly (no 500 server error from HTTP or exceptions from queue worker). 39 | - Super useful & helpful for mission-critical apps. 40 | 41 | ## Requirements 42 | - Laravel 9+ & 10+ 43 | - PHP 8.1 & 8.2 44 | 45 | ## Installation 46 | 47 | ```bash 48 | composer require shipsaas/safe-dispatcher 49 | ``` 50 | 51 | ## Usage 52 | 53 | ### Dependency Injection 54 | 55 | ```php 56 | use SaasSafeDispatcher\Bus\SafeDispatcher; 57 | 58 | class RegisterService 59 | { 60 | public function __construct(public SafeDispatcher $dispatcher) {} 61 | 62 | public function register(): void 63 | { 64 | $user = User::create(); 65 | 66 | $job = new SendEmailToRegisteredUser($user); 67 | $this->dispatcher->dispatch($job); 68 | } 69 | } 70 | ``` 71 | 72 | ### Use Trait for your Job 73 | 74 | ```php 75 | use SaasSafeDispatcher\Traits\SafeDispatchable; 76 | 77 | class SendEmailToRegisteredUser implements ShouldQueue 78 | { 79 | use SafeDispatchable; 80 | } 81 | 82 | SendEmailToRegisteredUser::safeDispatch($user); 83 | ``` 84 | 85 | ### Quick Helper Functions 86 | 87 | ```php 88 | safeDispatch(new SendEmailToRegisteredUser($user)); 89 | 90 | safeDispatch(() => echo('Hello')); 91 | 92 | safeDispatchSync(new SendEmailToRegisteredUser($user)); 93 | ``` 94 | 95 | ### Cover Queue Facade (v1.2.0+) 96 | 97 | ```php 98 | use SaasSafeDispatcher\Services\SafeQueue; 99 | 100 | SafeQueue::prepareFor(new Job()) 101 | ->push('high'); # Push to "high" queue name 102 | ``` 103 | 104 | Learn more [Cover Queue Facade](./docs/QueueFacade.md) 105 | 106 | ## Notes 107 | 108 | - SafeDispatcher hasn't supported with batching & chaining. 109 | - Alternatively, you can do normal `::safeDispatch` and after finish your job, dispatch another,... 110 | - SafeDispatcher considers the `sync` Queue as a Queue Msg. 111 | - Therefore, if the handling fails, Queue Msg will be stored too. 112 | - SafeDispatcher ships some helpful APIs too, check it out: [APIs](./docs/APIs.md) 113 | 114 | ## Tests 115 | SafeDispatcher is not only have normal Unit Testing but also Integration Test (interacting with MySQL for DB and Redis for Queue). 116 | 117 | We're planning to add other queue drivers too (database or SQS). 118 | 119 | To run the test, hit this: 120 | 121 | ```bash 122 | composer test 123 | ``` 124 | 125 | ## Contribute to the project 126 | - All changes must follow PSR-1 / PSR-12 coding conventions. 127 | - Unit testing is a must, cover things as much as you can. 128 | 129 | ### Maintainers & Contributors 130 | - Seth Phat 131 | 132 | Join me 😉 133 | 134 | ## This library is useful? 135 | Thank you, please give it a ⭐️⭐️⭐️ to support the project. 136 | 137 | Don't forget to share with your friends & colleagues 🚀 138 | 139 | ## License 140 | Copyright © by ShipSaaS 2023 - Under MIT License. 141 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shipsaas/safe-dispatcher", 3 | "type": "library", 4 | "version": "1.2.0", 5 | "description": "Ensure your Queue msg is tracked and retryable when failed to dispatch.", 6 | "keywords": [ 7 | "laravel library", 8 | "laravel safe dispatcher", 9 | "laravel safe dispatch ensure your Queue Job Msgs are totally tracked and resendable" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Phat Tran (Seth Phat)", 14 | "email": "phattranminh96@gmail.com", 15 | "homepage": "https://github.com/sethsandaru", 16 | "role": "Sr.SWE" 17 | } 18 | ], 19 | "license": "MIT", 20 | "require": { 21 | "php": "^8.1|^8.2", 22 | "laravel/framework": "^9|^10|dev-master" 23 | }, 24 | "require-dev": { 25 | "fakerphp/faker": "^v1.20.0", 26 | "mockery/mockery": "^1.5.1", 27 | "phpunit/phpunit": "^9.5.25", 28 | "orchestra/testbench": "^7|^8", 29 | "phpunit/php-code-coverage": "^9.2.17", 30 | "friendsofphp/php-cs-fixer": "^3.10" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "SaasSafeDispatcher\\SafeDispatcherServiceProvider" 36 | ] 37 | } 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "SaasSafeDispatcher\\": "src/" 42 | }, 43 | "files": [ 44 | "src/Functions/safe-dispatcher-functions.php" 45 | ] 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "SaasSafeDispatcher\\Tests\\": "tests/" 50 | } 51 | }, 52 | "scripts": { 53 | "test-coverage": [ 54 | "@php vendor/bin/phpunit --coverage-clover coverage.xml" 55 | ], 56 | "test": [ 57 | "@php vendor/bin/phpunit" 58 | ] 59 | }, 60 | "minimum-stability": "dev", 61 | "prefer-stable": true 62 | } 63 | -------------------------------------------------------------------------------- /docs/APIs.md: -------------------------------------------------------------------------------- 1 | # RESTFUL APIs of SafeDispatcher 2 | 3 | SafeDispatcher ship some useful endpoints for you to: 4 | 5 | - Listing all the failed to dispatch jobs 6 | - View a specific one 7 | - Retry 8 | 9 | Note: check the [safe-dispatcher.php](../src/Configs/safe-dispatcher.php) to see available configurations for the routes. 10 | 11 | ## Listing 12 | 13 | ``` 14 | GET safe-dispatcher-apis/failed-to-dispatch-jobs 15 | ?limit=xx // limit per page (int, min 10 max 100) 16 | &page=yy // current page (int) 17 | &job_class= // filter by job classname (null by default) 18 | &failed_from= // filter by created_at, >= created_at (datetime format: Y-m-d H:i:s), null by default 19 | &failed_to= // filter by created_at, <= created_at (datetime format: Y-m-d H:i:s), null by default 20 | &wants_redispatched // will show those jobs that already redispatched (boolean) false by default 21 | &sort_by= // created_at, job_class (created_at by default) 22 | &sort_direction= // asc, desc (desc by default) 23 | ``` 24 | 25 | ## View single failed to dispatch job 26 | 27 | ``` 28 | GET safe-dispatcher-apis/failed-to-dispatch-jobs/{uuid} 29 | ``` 30 | 31 | ## Retry a failed to dispatch job 32 | 33 | ``` 34 | PATCH safe-dispatcher-apis/failed-to-dispatch-jobs/{uuid} 35 | ``` 36 | 37 | Note: if the job already redispatched, it won't let you redispatch again. 38 | -------------------------------------------------------------------------------- /docs/GUI.md: -------------------------------------------------------------------------------- 1 | # Internal Dashboard - GUI of SafeDispatcher 2 | 3 | Highly inspired by the Laravel Horizon, SafeDispatcher would offer you the Internal Dashboard too. 4 | 5 | (Currently in development) 6 | 7 | Stay tuned! 8 | -------------------------------------------------------------------------------- /docs/QueueFacade.md: -------------------------------------------------------------------------------- 1 | # Queue Facade 2 | 3 | Queue Facade allows you to interact with the current/any Queue Connection directly. 4 | 5 | If you prefer using Queue Facade, please use our `SafeQueue` wrapper. 6 | 7 | Available on **v1.2.0**. 8 | 9 | ## Usage 10 | 11 | ```php 12 | use SaasSafeDispatcher\Services\SafeQueue; 13 | 14 | # Use "default" queue connection 15 | SafeQueue::prepareFor(new Job()) 16 | ->push(); # Push to default queue name 17 | 18 | SafeQueue::prepareFor(new Job()) 19 | ->push('high'); # Push to "high" queue name 20 | 21 | # Use "redis" queue connection 22 | SafeQueue::prepareFor(new Job(), 'redis') 23 | ->later(\Carbon\Carbon::now()->addMinutes(10)); # Push to "default" queue name 24 | 25 | SafeQueue::prepareFor(new Job(), 'redis') 26 | ->later(\Carbon\Carbon::now()->addMinutes(10), 'low'); # Push to "low" queue name 27 | ``` 28 | 29 | ## Notes 30 | 31 | ### Single Push 32 | `SafeQueue` only supports single push. So if you have a list of queued jobs, loop them 😆 33 | 34 | ```php 35 | collect($jobs)->each(fn ($job) => SafeQueue::prepareFor($job)->push()); 36 | ``` 37 | 38 | ### Strict Contract 39 | 40 | `SafeQueue` expects your job class must implement the `ShouldQueue`, please do so. 41 | 42 | Also, you can obviously add the connection, queue & delay from your job class as well 😉 43 | 44 | ### Love your life 45 | 46 | Remember to use `SafeQueue` over `Queue` facade 😉 47 | -------------------------------------------------------------------------------- /docs/SafeDispatcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipsaas/safe-dispatcher/3124252140ccd5d5bde181f00f86da4ec94a0e4b/docs/SafeDispatcher.png -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./src 16 | 17 | 18 | ./src/Configs 19 | ./src/Console 20 | ./src/Constants 21 | ./src/Contracts 22 | ./src/Database 23 | ./src/Facades 24 | ./src/Exceptions 25 | ./src/Routes 26 | ./src/SafeDispatcherServiceProvider.php 27 | 28 | 29 | 30 | 31 | ./tests/ 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Authorizations/BaseExternalCheck.php: -------------------------------------------------------------------------------- 1 | container 19 | ->make(FailDispatcherService::class) 20 | ->storeFailure($queue, $throwable, $command); 21 | 22 | return; 23 | } 24 | } 25 | 26 | public function dispatchNow($command, $handler = null) 27 | { 28 | try { 29 | return parent::dispatchNow($command, $handler); 30 | } catch (Throwable $throwable) { 31 | $this->container 32 | ->make(FailDispatcherService::class) 33 | ->storeFailure( 34 | $this->container->get(SyncQueue::class), 35 | $throwable, 36 | $command 37 | ); 38 | 39 | return; 40 | } 41 | } 42 | 43 | public function batch($jobs) 44 | { 45 | throw new RuntimeException('Not supported (just yet)'); 46 | } 47 | 48 | public function chain($jobs) 49 | { 50 | throw new RuntimeException('Not supported (just yet)'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Bus/SafePendingClosureDispatch.php: -------------------------------------------------------------------------------- 1 | job->onFailure($callback); 17 | 18 | return $this; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Bus/SafePendingDispatch.php: -------------------------------------------------------------------------------- 1 | shouldDispatch()) { 12 | return; 13 | } elseif ($this->afterResponse) { 14 | app(SafeDispatcher::class)->dispatchAfterResponse($this->job); 15 | } else { 16 | app(SafeDispatcher::class)->dispatch($this->job); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Configs/safe-dispatcher.php: -------------------------------------------------------------------------------- 1 | true, 9 | 10 | /** 11 | * Prefix of the existing APIs 12 | */ 13 | 'api_prefix_route' => 'safe-dispatcher-apis', 14 | 15 | /** 16 | * List of middlewares (for permissions checking) when accessing the APIs 17 | * 18 | * eg: ['web', MyMiddleware::class] 19 | */ 20 | 'api_middlewares' => [], 21 | ]; 22 | -------------------------------------------------------------------------------- /src/Database/Factories/FailedToDispatchJobFactory.php: -------------------------------------------------------------------------------- 1 | CallQueuedClosure::class, 19 | 'queue_connection' => 'sync', 20 | 'queue_name' => null, 21 | 'job_detail' => serialize(CallQueuedClosure::create(fn () => Log::info('hehe'))), 22 | 'errors' => 'Error', 23 | 'created_at' => Carbon::now(), 24 | 'updated_at' => Carbon::now(), 25 | 'redispatched_at' => null, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Database/Migrations/2023_02_04_104010_create_failed_to_dispatch_jobs.php: -------------------------------------------------------------------------------- 1 | uuid('id')->unique(); 12 | 13 | $table->string('job_class')->index(); 14 | $table->string('queue_connection')->nullable()->index(); 15 | $table->string('queue_name')->nullable()->index(); 16 | 17 | $table->longText('job_detail'); 18 | $table->json('errors'); 19 | 20 | $table->timestamps(); 21 | $table->timestamp('redispatched_at')->nullable(); 22 | 23 | $table->index('created_at'); 24 | $table->index('updated_at'); 25 | $table->index('redispatched_at'); 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/Functions/safe-dispatcher-functions.php: -------------------------------------------------------------------------------- 1 | dispatchSync($job, $handler); 33 | } 34 | // @codeCoverageIgnoreStart 35 | } 36 | // @codeCoverageIgnoreEnd 37 | -------------------------------------------------------------------------------- /src/Http/Controllers/FailedToDispatchJobController.php: -------------------------------------------------------------------------------- 1 | computeQueryBuilder() 19 | ->paginate($request->integer('limit') ?: 10); 20 | 21 | return FailedToDispatchJobResource::collection($query)->response(); 22 | } 23 | 24 | public function show( 25 | FailedToDispatchJobViewRequest $request, 26 | FailedToDispatchJob $failedToDispatchJob 27 | ): JsonResponse { 28 | return (new FailedToDispatchJobResource($failedToDispatchJob))->response(); 29 | } 30 | 31 | public function retry( 32 | FailedToDispatchJobRetryRequest $request, 33 | FailedToDispatchJob $failedToDispatchJob, 34 | FailDispatcherService $failDispatcherService 35 | ): JsonResponse { 36 | $failDispatcherService->redispatch($failedToDispatchJob); 37 | 38 | return new JsonResponse(['success' => true]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Http/Requests/FailedToDispatchJobIndexRequest.php: -------------------------------------------------------------------------------- 1 | 'nullable|integer|min:10|max:100', 22 | 'page' => 'nullable|integer', 23 | 'job_class' => 'nullable|string', 24 | 'failed_from' => [ 25 | 'nullable', 26 | 'date_format:Y-m-d H:i:s', 27 | ], 28 | 'failed_to' => [ 29 | 'nullable', 30 | 'date_format:Y-m-d H:i:s', 31 | ], 32 | 'wants_redispatched' => 'nullable|boolean', 33 | 'sort_by' => [ 34 | 'nullable', 35 | 'string', 36 | Rule::in([ 37 | 'created_at', 38 | 'job_class', 39 | ]), 40 | ], 41 | 'sort_direction' => [ 42 | 'nullable', 43 | 'string', 44 | Rule::in([ 45 | 'asc', 46 | 'desc', 47 | ]), 48 | ], 49 | ]; 50 | } 51 | 52 | public function computeQueryBuilder(): Builder 53 | { 54 | return FailedToDispatchJob::query() 55 | ->orderBy( 56 | $this->input('sort_by') ?: 'created_at', 57 | $this->input('sort_direction') ?: 'DESC' 58 | )->when( 59 | $this->boolean('wants_redispatched'), 60 | fn ($q) => $q->whereNotNull('redispatched_at') 61 | )->when( 62 | $this->filled('job_class'), 63 | fn ($q) => $q->where('job_class', $this->input('job_class')) 64 | )->when( 65 | $this->filled('failed_from'), 66 | fn ($q) => $q->where('created_at', '>=', $this->input('failed_from')) 67 | )->when( 68 | $this->filled('failed_to'), 69 | fn ($q) => $q->where('created_at', '<=', $this->input('failed_to')) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Http/Requests/FailedToDispatchJobRetryRequest.php: -------------------------------------------------------------------------------- 1 | $this->id, 20 | 'job_class' => $this->job_class, 21 | 'connection' => $this->queue_connection, 22 | 'job_detail' => $this->job_detail, 23 | 'queue' => $this->queue_name, 24 | 'errors' => $this->errors, 25 | 'created_at' => $this->created_at, 26 | 'redispatched_at' => $this->redispatched_at, 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Models/FailedToDispatchJob.php: -------------------------------------------------------------------------------- 1 | 'array', 28 | ]; 29 | 30 | public function getJobObject(): mixed 31 | { 32 | return unserialize($this->job_detail); 33 | } 34 | 35 | protected static function newFactory(): FailedToDispatchJobFactory 36 | { 37 | return FailedToDispatchJobFactory::new(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Routes/safe-dispatcher-routes.php: -------------------------------------------------------------------------------- 1 | name('safe-dispatcher.') 10 | ->middleware([ 11 | SubstituteBindings::class, 12 | ...config('safe-dispatcher.api_middlewares'), 13 | ]) 14 | ->group(function () { 15 | Route::get('/failed-to-dispatch-jobs', [FailedToDispatchJobController::class, 'index']) 16 | ->name('failed-to-dispatch-jobs.index'); 17 | Route::get('/failed-to-dispatch-jobs/{failedToDispatchJob}', [FailedToDispatchJobController::class, 'show']) 18 | ->name('failed-to-dispatch-jobs.show'); 19 | Route::patch('/failed-to-dispatch-jobs/{failedToDispatchJob}', [FailedToDispatchJobController::class, 'retry']) 20 | ->name('failed-to-dispatch-jobs.retry'); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/SafeDispatcherServiceProvider.php: -------------------------------------------------------------------------------- 1 | ['Version' => '1.2.0']); 18 | 19 | $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations'); 20 | $this->mergeConfigFrom(__DIR__ . '/Configs/safe-dispatcher.php', 'safe-dispatcher'); 21 | 22 | $this->publishes([ 23 | __DIR__ . '/Configs/safe-dispatcher.php' => config_path('safe-dispatcher.php'), 24 | ], 'safe-dispatcher'); 25 | 26 | $this->loadRoutesFrom(__DIR__ . '/Routes/safe-dispatcher-routes.php'); 27 | 28 | Route::model('failedToDispatchJob', FailedToDispatchJob::class); 29 | } 30 | 31 | public function register(): void 32 | { 33 | $this->app->bind(SafeDispatcher::class, function ($app) { 34 | return new SafeDispatcher($app, function ($connection = null) use ($app) { 35 | return $app[QueueFactoryContract::class]->connection($connection); 36 | }); 37 | }); 38 | } 39 | 40 | public function provides(): array 41 | { 42 | return [ 43 | SafeDispatcher::class, 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Services/FailDispatcherService.php: -------------------------------------------------------------------------------- 1 | get_class($command), 22 | 'job_detail' => serialize($command), 23 | 'queue_name' => $command->queue ?? null, 24 | 'queue_connection' => $queueDriver->getConnectionName(), 25 | 'errors' => [ 26 | 'msg' => $throwable->getMessage(), 27 | 'traces' => $throwable->getTrace(), 28 | ], 29 | ]); 30 | } 31 | 32 | /** 33 | * Redispatch a specific job 34 | */ 35 | public function redispatch( 36 | FailedToDispatchJob $failedToDispatchJob, 37 | ?RedispatchOption $option = null 38 | ): void { 39 | $job = $failedToDispatchJob->getJobObject(); 40 | 41 | if ($option?->connection && method_exists($job, 'onConnection')) { 42 | $job->onConnection($option->connection); 43 | } 44 | 45 | if ($option?->queue && method_exists($job, 'onQueue')) { 46 | $job->onQueue($option->queue); 47 | } 48 | 49 | app(SafeDispatcher::class)->dispatch($job); 50 | 51 | $failedToDispatchJob->touch('redispatched_at'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Services/RedispatchOption.php: -------------------------------------------------------------------------------- 1 | connection); 28 | } 29 | 30 | private function pushSafety(callable $pushInvoker): void 31 | { 32 | try { 33 | call_user_func($pushInvoker); 34 | } catch (Throwable $throwable) { 35 | app(FailDispatcherService::class) 36 | ->storeFailure( 37 | $this->getQueueConnection(), 38 | $throwable, 39 | $this->job 40 | ); 41 | } 42 | } 43 | 44 | public function push(?string $queue = null): void 45 | { 46 | $this->pushSafety( 47 | fn () => $this->getQueueConnection()->push($this->job, queue: $queue) 48 | ); 49 | } 50 | 51 | public function later( 52 | DateTimeInterface|DateInterval|int $delay, 53 | ?string $queue = null 54 | ): void { 55 | $this->pushSafety( 56 | fn () => $this->getQueueConnection()->later($delay, $this->job, queue: $queue) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Traits/SafeDispatchable.php: -------------------------------------------------------------------------------- 1 | count(2) 15 | ->create(); 16 | 17 | $this->json('GET', route('safe-dispatcher.failed-to-dispatch-jobs.index')) 18 | ->assertOk() 19 | ->assertJsonFragment([ 20 | 'id' => $jobs[0]->id, 21 | ]) 22 | ->assertJsonFragment([ 23 | 'id' => $jobs[1]->id, 24 | ]); 25 | } 26 | 27 | public function testIndexFiltersByJobClass() 28 | { 29 | $jobs = FailedToDispatchJob::factory() 30 | ->count(2) 31 | ->sequence( 32 | [ 33 | 'job_class' => 'TestClass', 34 | ], 35 | [ 36 | 'job_class' => CallQueuedClosure::class, 37 | ] 38 | )->create(); 39 | 40 | $this->json('GET', route('safe-dispatcher.failed-to-dispatch-jobs.index'), [ 41 | 'job_class' => CallQueuedClosure::class, 42 | ]) 43 | ->assertOk() 44 | ->assertJsonMissing([ 45 | 'id' => $jobs[0]->id, 46 | ]) 47 | ->assertJsonFragment([ 48 | 'id' => $jobs[1]->id, 49 | ]); 50 | } 51 | 52 | public function testIndexFiltersByFailedFrom() 53 | { 54 | $jobs = FailedToDispatchJob::factory() 55 | ->count(2) 56 | ->sequence( 57 | [ 58 | 'created_at' => '2023-01-02 10:00:00', 59 | ], 60 | [ 61 | 'created_at' => '2023-01-01 11:00:00', 62 | ] 63 | )->create(); 64 | 65 | $this->json('GET', route('safe-dispatcher.failed-to-dispatch-jobs.index'), [ 66 | 'failed_from' => '2023-01-02 00:00:00', 67 | ]) 68 | ->assertOk() 69 | ->assertJsonFragment([ 70 | 'id' => $jobs[0]->id, 71 | ]) 72 | ->assertJsonMissing([ 73 | 'id' => $jobs[1]->id, 74 | ]); 75 | } 76 | 77 | public function testIndexFiltersByFailedTo() 78 | { 79 | $jobs = FailedToDispatchJob::factory() 80 | ->count(3) 81 | ->sequence( 82 | [ 83 | 'created_at' => '2023-01-02 10:00:00', 84 | ], 85 | [ 86 | 'created_at' => '2023-01-01 11:00:00', 87 | ], 88 | [ 89 | 'created_at' => '2023-01-05 12:00:00', 90 | ] 91 | )->create(); 92 | 93 | $this->json('GET', route('safe-dispatcher.failed-to-dispatch-jobs.index'), [ 94 | 'failed_to' => '2023-01-03 00:00:00', 95 | ]) 96 | ->assertOk() 97 | ->assertJsonFragment([ 98 | 'id' => $jobs[0]->id, 99 | ]) 100 | ->assertJsonFragment([ 101 | 'id' => $jobs[1]->id, 102 | ]) 103 | ->assertJsonMissing([ 104 | 'id' => $jobs[2]->id, 105 | ]); 106 | } 107 | 108 | public function testShowReturnsSingleInstance() 109 | { 110 | /** @var FailedToDispatchJob $job */ 111 | $job = FailedToDispatchJob::factory()->create(); 112 | 113 | $this->json('GET', route('safe-dispatcher.failed-to-dispatch-jobs.show', [$job])) 114 | ->assertOk() 115 | ->assertJsonFragment([ 116 | 'id' => $job->id, 117 | 'job_class' => $job->job_class, 118 | 'errors' => $job->errors, 119 | ]); 120 | } 121 | 122 | public function testRetrySuccessfully() 123 | { 124 | /** @var FailedToDispatchJob $job */ 125 | $job = FailedToDispatchJob::factory()->create(); 126 | 127 | $this->json('PATCH', route('safe-dispatcher.failed-to-dispatch-jobs.retry', [$job])) 128 | ->assertOk(); 129 | 130 | $job->refresh(); 131 | 132 | $this->assertNotNull($job->redispatched_at); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/Integration/RedisQueueTest.php: -------------------------------------------------------------------------------- 1 | 'redis', 28 | ]); 29 | 30 | QueueJob::safeDispatch('SafeDispatch'); 31 | 32 | $this->assertSame(1, app(QueueManager::class)->connection()->size()); 33 | 34 | // works fine 35 | $this->artisan('queue:work redis --max-jobs=1')->assertOk(); 36 | 37 | $this->assertSame(0, app(QueueManager::class)->connection()->size()); 38 | } 39 | 40 | /** 41 | * Dispatch closure job queue msg 42 | */ 43 | public function testDispatchClosureJobOk() 44 | { 45 | config([ 46 | 'queue.default' => 'redis', 47 | ]); 48 | 49 | safeDispatch(function () { 50 | return 'Hello World'; 51 | }); 52 | 53 | $this->assertSame(1, app(QueueManager::class)->connection()->size()); 54 | 55 | // works fine 56 | $this->artisan('queue:work redis --max-jobs=1')->assertOk(); 57 | 58 | $this->assertSame(0, app(QueueManager::class)->connection()->size()); 59 | } 60 | 61 | /** 62 | * Redispatch normal queue msg to another driver 63 | */ 64 | public function testRedispatchFailedToDispatchJobOk() 65 | { 66 | config([ 67 | 'queue.default' => 'null', 68 | ]); 69 | 70 | $nullQueueDriver = $this->createMock(NullQueue::class); 71 | $nullQueueDriver->method('setContainer')->willReturnSelf(); 72 | $nullQueueDriver->method('setConnectionName')->willReturnSelf(); 73 | $nullQueueDriver->expects($this->once()) 74 | ->method('push') 75 | ->willThrowException(new RuntimeException('Cannot dispatch job')); 76 | 77 | app(QueueManager::class) 78 | ->addConnector('null', fn () => new class ($nullQueueDriver) extends NullConnector { 79 | public function __construct(public NullQueue $nullQueue) 80 | { 81 | } 82 | 83 | public function connect(array $config) 84 | { 85 | return $this->nullQueue; 86 | } 87 | }); 88 | 89 | // 1. Failed to dispatch 90 | QueueJob::safeDispatch('SafeDispatch'); 91 | 92 | $this->assertDatabaseHas((new FailedToDispatchJob())->getTable(), [ 93 | 'queue_connection' => null, 94 | 'job_class' => QueueJob::class, 95 | 'errors->msg' => 'Cannot dispatch job', 96 | ]); 97 | 98 | // 2. Redispatch 99 | $storedFailedToDispatchJob = FailedToDispatchJob::where([ 100 | 'job_class' => QueueJob::class, 101 | ])->first(); 102 | 103 | $jobObject = $storedFailedToDispatchJob?->getJobObject(); 104 | 105 | $this->assertNotNull($storedFailedToDispatchJob); 106 | $this->assertNotNull($jobObject); 107 | $this->assertInstanceOf(ShouldQueue::class, $jobObject); 108 | 109 | $redispatchOption = new RedispatchOption(); 110 | $redispatchOption->connection = 'redis'; 111 | app(FailDispatcherService::class)->redispatch( 112 | $storedFailedToDispatchJob, 113 | $redispatchOption 114 | ); 115 | 116 | $storedFailedToDispatchJob->refresh(); 117 | $this->assertNotNull($storedFailedToDispatchJob->redispatched_at); 118 | 119 | // 3. Final assertion & queue work 120 | $this->assertSame(1, app(QueueManager::class)->connection('redis')->size()); 121 | 122 | $this->artisan('queue:work redis --max-jobs=1')->assertOk(); 123 | 124 | $this->assertSame(0, app(QueueManager::class)->connection('redis')->size()); 125 | } 126 | 127 | /** 128 | * Redispatch closure queue msg to another driver & queue 129 | */ 130 | public function testRedispatchFailedToDispatchClosureJobOk() 131 | { 132 | config([ 133 | 'queue.default' => 'null', 134 | ]); 135 | 136 | $nullQueueDriver = $this->createMock(NullQueue::class); 137 | $nullQueueDriver->method('setContainer')->willReturnSelf(); 138 | $nullQueueDriver->method('setConnectionName')->willReturnSelf(); 139 | $nullQueueDriver->expects($this->once()) 140 | ->method('push') 141 | ->willThrowException(new RuntimeException('Cannot dispatch job')); 142 | 143 | app(QueueManager::class) 144 | ->addConnector('null', fn () => new class ($nullQueueDriver) extends NullConnector { 145 | public function __construct(public NullQueue $nullQueue) 146 | { 147 | } 148 | 149 | public function connect(array $config) 150 | { 151 | return $this->nullQueue; 152 | } 153 | }); 154 | 155 | // 1. Failed to dispatch 156 | safeDispatch(function () { 157 | return 'Hihi'; 158 | }); 159 | 160 | $this->assertDatabaseHas((new FailedToDispatchJob())->getTable(), [ 161 | 'job_class' => CallQueuedClosure::class, 162 | 'errors->msg' => 'Cannot dispatch job', 163 | ]); 164 | 165 | // 2. Redispatch 166 | $storedFailedToDispatchJob = FailedToDispatchJob::where([ 167 | 'job_class' => CallQueuedClosure::class, 168 | ])->first(); 169 | 170 | $jobObject = $storedFailedToDispatchJob?->getJobObject(); 171 | 172 | $this->assertNotNull($storedFailedToDispatchJob); 173 | $this->assertNotNull($jobObject); 174 | $this->assertInstanceOf(ShouldQueue::class, $jobObject); 175 | 176 | $redispatchOption = new RedispatchOption(); 177 | $redispatchOption->connection = 'redis'; // push to another driver 178 | $redispatchOption->queue = 'high'; // push to another queue too 179 | app(FailDispatcherService::class)->redispatch( 180 | $storedFailedToDispatchJob, 181 | $redispatchOption 182 | ); 183 | 184 | $storedFailedToDispatchJob->refresh(); 185 | $this->assertNotNull($storedFailedToDispatchJob->redispatched_at); 186 | 187 | // 3. Final assertion & queue work 188 | $this->assertSame(1, app(QueueManager::class)->connection('redis')->size('high')); 189 | 190 | $this->artisan('queue:work redis --queue=high --max-jobs=1')->assertOk(); 191 | 192 | $this->assertSame(0, app(QueueManager::class)->connection('redis')->size('high')); 193 | } 194 | } 195 | 196 | class QueueJob implements ShouldQueue 197 | { 198 | use Queueable; 199 | use SafeDispatchable; 200 | 201 | public function __construct(public string $hello) 202 | { 203 | } 204 | 205 | public function handle(): void 206 | { 207 | Log::info('Hello ' . $this->hello); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | artisan('queue:clear redis'); 18 | parent::tearDown(); 19 | } 20 | 21 | protected function getPackageProviders($app): array 22 | { 23 | return [ 24 | SafeDispatcherServiceProvider::class, 25 | ]; 26 | } 27 | 28 | protected function getEnvironmentSetUp($app): void 29 | { 30 | $migrationFiles = [ 31 | __DIR__ . '/../src/Database/Migrations/2023_02_04_104010_create_failed_to_dispatch_jobs.php', 32 | ]; 33 | 34 | foreach ($migrationFiles as $migrationFile) { 35 | $migrateInstance = include $migrationFile; 36 | $migrateInstance->up(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/BaseExternalCheckTest.php: -------------------------------------------------------------------------------- 1 | assertFalse(FailedToDispatchRetryCheck::authorize(new FormRequest())); 18 | 19 | FailedToDispatchRetryCheck::setCheck(function () { 20 | return true; 21 | }); 22 | 23 | $this->assertTrue(FailedToDispatchRetryCheck::authorize(new FormRequest())); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Unit/SafeDispatcherTest.php: -------------------------------------------------------------------------------- 1 | 'null', 23 | ]); 24 | 25 | QueueJob::safeDispatch('SafeDispatch'); 26 | 27 | // no exception => all good 28 | $this->assertTrue(true); 29 | } 30 | 31 | public function testDispatchQueueFailed() 32 | { 33 | config([ 34 | 'queue.default' => 'null', 35 | ]); 36 | 37 | $nullQueueDriver = $this->createMock(NullQueue::class); 38 | $nullQueueDriver->method('setContainer')->willReturnSelf(); 39 | $nullQueueDriver->method('setConnectionName')->willReturnSelf(); 40 | $nullQueueDriver->expects($this->once()) 41 | ->method('push') 42 | ->willThrowException(new RuntimeException('Cannot dispatch job')); 43 | 44 | app(QueueManager::class) 45 | ->addConnector('null', fn () => new class ($nullQueueDriver) extends NullConnector { 46 | public function __construct(public NullQueue $nullQueue) 47 | { 48 | } 49 | 50 | public function connect(array $config) 51 | { 52 | return $this->nullQueue; 53 | } 54 | }); 55 | 56 | QueueJob::safeDispatch('SafeDispatch'); 57 | 58 | $this->assertDatabaseHas((new FailedToDispatchJob())->getTable(), [ 59 | 'job_class' => QueueJob::class, 60 | 'errors->msg' => 'Cannot dispatch job', 61 | ]); 62 | } 63 | 64 | public function testDispatchNowOk() 65 | { 66 | $job = new QueueJob('Seth Phat'); 67 | 68 | Log::expects('info') 69 | ->once() 70 | ->with('Hello Seth Phat'); 71 | 72 | app(SafeDispatcher::class)->dispatchSync($job); 73 | } 74 | 75 | public function testDispatchNowFailed() 76 | { 77 | $job = new QueueJob('Seth Phat'); 78 | 79 | Log::expects('info') 80 | ->once() 81 | ->with('Hello Seth Phat') 82 | ->andThrow(new RuntimeException('Job Failed to process')); 83 | 84 | // for sync & now, since it will be processed in the same/synchronous process 85 | // so if the handle failed => consider it a failed to dispatch 86 | app(SafeDispatcher::class)->dispatchSync($job); 87 | 88 | $this->assertDatabaseHas((new FailedToDispatchJob())->getTable(), [ 89 | 'job_class' => QueueJob::class, 90 | 'errors->msg' => 'Job Failed to process', 91 | ]); 92 | } 93 | 94 | public function testBatchIsNotSupported() 95 | { 96 | $this->expectException(RuntimeException::class); 97 | 98 | app(SafeDispatcher::class)->batch([]); 99 | } 100 | 101 | public function testChainIsNotSupported() 102 | { 103 | $this->expectException(RuntimeException::class); 104 | 105 | app(SafeDispatcher::class)->chain([]); 106 | } 107 | } 108 | 109 | class QueueJob implements ShouldQueue 110 | { 111 | use Queueable; 112 | use SafeDispatchable; 113 | 114 | public function __construct(public string $hello) 115 | { 116 | } 117 | 118 | public function handle(): void 119 | { 120 | Log::info('Hello ' . $this->hello); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Unit/SafePendingDispatchTest.php: -------------------------------------------------------------------------------- 1 | createPartialMock(SafePendingDispatch::class, [ 14 | 'shouldDispatch', 15 | ]); 16 | $job->method('shouldDispatch')->willReturn(false); 17 | 18 | $safeDispatcher = $this->createMock(SafeDispatcher::class); 19 | $safeDispatcher->expects($this->never())->method('dispatchAfterResponse'); 20 | $safeDispatcher->expects($this->never())->method('dispatch'); 21 | 22 | $this->app->offsetSet(SafeDispatcher::class, $safeDispatcher); 23 | 24 | $job->__destruct(); 25 | } 26 | 27 | public function testDispatchAfterResponse() 28 | { 29 | $job = $this->createPartialMock(SafePendingDispatch::class, [ 30 | 'shouldDispatch', 31 | ]); 32 | $job->method('shouldDispatch')->willReturn(true); 33 | $job->afterResponse(); 34 | 35 | $safeDispatcher = $this->createMock(SafeDispatcher::class); 36 | $safeDispatcher->expects($this->once())->method('dispatchAfterResponse'); 37 | $safeDispatcher->expects($this->never())->method('dispatch'); 38 | 39 | $this->app->offsetSet(SafeDispatcher::class, $safeDispatcher); 40 | 41 | $job->__destruct(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Unit/SafeQueueTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(SafeQueue::class, $safeQueue); 21 | } 22 | 23 | public function testPushOk() 24 | { 25 | Queue::fake([TestJob::class]); 26 | 27 | SafeQueue::prepareFor(new TestJob())->push(); 28 | 29 | Queue::assertPushed(TestJob::class); 30 | } 31 | 32 | public function testPushOnQueueOk() 33 | { 34 | Queue::fake([TestJob::class]); 35 | 36 | SafeQueue::prepareFor(new TestJob())->push('test'); 37 | 38 | Queue::assertPushedOn('test', TestJob::class); 39 | } 40 | 41 | public function testPushNotOkWillLog() 42 | { 43 | config([ 44 | 'queue.default' => 'null', 45 | ]); 46 | 47 | $nullQueueDriver = $this->createMock(NullQueue::class); 48 | $nullQueueDriver->method('setContainer')->willReturnSelf(); 49 | $nullQueueDriver->method('setConnectionName')->willReturnSelf(); 50 | $nullQueueDriver->expects($this->once()) 51 | ->method('push') 52 | ->willThrowException(new RuntimeException('Cannot push job')); 53 | 54 | app(QueueManager::class) 55 | ->addConnector('null', fn () => new class ($nullQueueDriver) extends NullConnector { 56 | public function __construct(public NullQueue $nullQueue) 57 | { 58 | } 59 | 60 | public function connect(array $config) 61 | { 62 | return $this->nullQueue; 63 | } 64 | }); 65 | 66 | SafeQueue::prepareFor(new TestJob())->push(); 67 | 68 | $this->assertDatabaseHas((new FailedToDispatchJob())->getTable(), [ 69 | 'queue_connection' => null, 70 | 'queue_name' => null, 71 | 'job_class' => TestJob::class, 72 | 'errors->msg' => 'Cannot push job', 73 | ]); 74 | 75 | } 76 | 77 | public function testPushLaterOk() 78 | { 79 | Queue::fake([TestJob::class]); 80 | 81 | SafeQueue::prepareFor(new TestJob())->later(now()->addSeconds()); 82 | 83 | Queue::assertPushed(TestJob::class); 84 | } 85 | 86 | public function testPushLaterOnQueueOk() 87 | { 88 | Queue::fake([TestJob::class]); 89 | 90 | SafeQueue::prepareFor(new TestJob())->later(now()->addSeconds(), 'hehe'); 91 | 92 | Queue::assertPushedOn('hehe', TestJob::class); 93 | } 94 | } 95 | 96 | class TestJob implements ShouldQueue 97 | { 98 | } 99 | --------------------------------------------------------------------------------