├── .env.example ├── .github ├── ISSUE_TEMPLATE │ └── issue-template.md └── workflows │ ├── code-analysis.yml │ ├── code-style.yml │ └── run-tests.yml ├── .gitignore ├── LICENCE ├── README.md ├── UPGRADING.md ├── app.Dockerfile ├── composer.json ├── composer.lock ├── config └── cloud-tasks.php ├── docker-compose.yml ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── src ├── CloudTasksApi.php ├── CloudTasksApiConcrete.php ├── CloudTasksApiContract.php ├── CloudTasksApiFake.php ├── CloudTasksConnector.php ├── CloudTasksJob.php ├── CloudTasksQueue.php ├── CloudTasksServiceProvider.php ├── Events │ ├── JobReleased.php │ ├── TaskCreated.php │ └── TaskIncoming.php ├── IncomingTask.php ├── TaskHandler.php └── Worker.php └── tests ├── CloudTasksApiTest.php ├── ConfigHandlerTest.php ├── IncomingTaskTest.php ├── QueueAppEngineTest.php ├── QueueTest.php ├── Support ├── BaseJob.php ├── DispatchedJob.php ├── EncryptedJob.php ├── FailingJob.php ├── FailingJobWithExponentialBackoff.php ├── FailingJobWithMaxTries.php ├── FailingJobWithMaxTriesAndRetryUntil.php ├── FailingJobWithNoMaxTries.php ├── FailingJobWithRetryUntil.php ├── FailingJobWithUnlimitedTries.php ├── JobOutput.php ├── JobThatWillBeReleased.php ├── SimpleJob.php ├── SimpleJobWithTimeout.php ├── User.php ├── UserJob.php ├── gcloud-key-dummy.json └── gcloud-key-valid.json ├── TaskHandlerTest.php └── TestCase.php /.env.example: -------------------------------------------------------------------------------- 1 | DB_DRIVER=mysql 2 | DB_HOST=mysql 3 | DB_PORT=3306 4 | 5 | CI_CLOUD_TASKS_PROJECT_ID= 6 | CI_CLOUD_TASKS_QUEUE= 7 | CI_CLOUD_TASKS_LOCATION= 8 | CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL= 9 | CI_SERVICE_ACCOUNT_JSON_KEY_PATH=./tests/Support/gcloud-key-valid.json 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Help you create an effective issue and know what to expect. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Thank you for taking the time for reporting a bug, requesting a feature, or letting me know something else about the package. 11 | 12 | I create open-source packages for fun while working full-time and running my own business. That means I don't have as much time left to maintain these packages, build elaborate new features or investigate and fix bugs. If you wish to get a feature or bugfix merged it would be greatly appreciated if you can provide as much info as possible and preferably a Pull Request ready with automated tests. Realistically I check Github a few times a week, and take several days, weeks or sometimes months before finishing features/bugfixes (depending on their size of course). 13 | 14 | Thanks for understanding. 😁 15 | -------------------------------------------------------------------------------- /.github/workflows/code-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Code analysis 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | - 'phpstan.neon' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | php-code-styling: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | with: 21 | ref: ${{ github.head_ref }} 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: '8.3' 27 | coverage: none 28 | 29 | - name: Install dependencies 30 | run: | 31 | composer install --no-interaction --prefer-dist 32 | 33 | - name: Run code analysis 34 | run: | 35 | composer run larastan -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code style 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | php-code-styling: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.head_ref }} 21 | 22 | - name: Check code style 23 | uses: aglipanci/laravel-pint-action@v2 24 | 25 | - name: Commit changes 26 | uses: stefanzweifel/git-auto-commit-action@v5 27 | with: 28 | commit_message: Apply code style rules -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request_target: 5 | types: [ opened, synchronize, labeled ] 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | access_check: 11 | runs-on: ubuntu-latest 12 | name: Access check 13 | steps: 14 | - name: Ensure pull-request is safe to run 15 | uses: actions/github-script@v7 16 | with: 17 | github-token: ${{secrets.GITHUB_TOKEN}} 18 | script: | 19 | if (context.eventName === 'schedule') { 20 | return 21 | } 22 | 23 | // If the user that pushed the commit is a maintainer, skip the check 24 | const collaborators = await github.rest.repos.listCollaborators({ 25 | owner: context.repo.owner, 26 | repo: context.repo.repo 27 | }); 28 | 29 | if (collaborators.data.some(c => c.login === context.actor)) { 30 | console.log(`User ${context.actor} is allowed to run tests because they are a collaborator.`); 31 | return 32 | } 33 | 34 | const issue_number = context.issue.number; 35 | const repository = context.repo.repo; 36 | const owner = context.repo.owner; 37 | 38 | const response = await github.rest.issues.listLabelsOnIssue({ 39 | owner, 40 | repo: repository, 41 | issue_number 42 | }); 43 | const labels = response.data.map(label => label.name); 44 | let hasLabel = labels.includes('safe-to-test') 45 | 46 | if (context.payload.action === 'synchronize' && hasLabel) { 47 | hasLabel = false 48 | await github.rest.issues.removeLabel({ 49 | owner, 50 | repo: repository, 51 | issue_number, 52 | name: 'safe-to-test' 53 | }); 54 | } 55 | 56 | if (!hasLabel) { 57 | throw "Action was not authorized. Exiting now." 58 | } 59 | 60 | php-tests: 61 | runs-on: ubuntu-latest 62 | needs: access_check 63 | strategy: 64 | matrix: 65 | db: 66 | - { driver: 'mysql', version: '8.0' } 67 | - { driver: 'mysql', version: '8.4' } 68 | - { driver: 'pgsql', version: '14' } 69 | - { driver: 'pgsql', version: '15' } 70 | - { driver: 'pgsql', version: '16' } 71 | - { driver: 'pgsql', version: '17' } 72 | payload: 73 | - { queue: 'github-actions-laravel11-php82', laravel: '11.*', php: '8.2', 'testbench': '9.*' } 74 | - { queue: 'github-actions-laravel11-php83', laravel: '11.*', php: '8.3', 'testbench': '9.*' } 75 | - { queue: 'github-actions-laravel11-php84', laravel: '11.*', php: '8.4', 'testbench': '9.*' } 76 | - { queue: 'github-actions-laravel12-php82', laravel: '12.*', php: '8.2', 'testbench': '10.*' } 77 | - { queue: 'github-actions-laravel12-php83', laravel: '12.*', php: '8.3', 'testbench': '10.*' } 78 | - { queue: 'github-actions-laravel12-php84', laravel: '12.*', php: '8.4', 'testbench': '10.*' } 79 | 80 | name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} - DB ${{ matrix.db.driver }} ${{ matrix.db.version }} 81 | 82 | steps: 83 | - name: Checkout code 84 | uses: actions/checkout@v4 85 | with: 86 | ref: ${{ github.event.pull_request.head.sha }} 87 | 88 | - name: Setup PHP 89 | uses: shivammathur/setup-php@v2 90 | with: 91 | php-version: ${{ matrix.payload.php }} 92 | extensions: mbstring, dom, fileinfo 93 | coverage: none 94 | 95 | - name: Set up MySQL and PostgreSQL 96 | env: 97 | CI_SERVICE_ACCOUNT_JSON_KEY: ${{ secrets.CI_SERVICE_ACCOUNT_JSON_KEY }} 98 | run: | 99 | touch .env 100 | if [ "${{ matrix.db.driver }}" = "mysql" ]; then 101 | MYSQL_PORT=3307 MYSQL_VERSION=${{ matrix.db.version }} docker compose up ${{ matrix.db.driver }} -d 102 | elif [ "${{ matrix.db.driver }}" = "pgsql" ]; then 103 | POSTGRES_PORT=5432 PGSQL_VERSION=${{ matrix.db.version }} docker compose up ${{ matrix.db.driver }} -d 104 | fi 105 | - name: Install dependencies 106 | run: | 107 | composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" --no-interaction --no-update 108 | composer update --prefer-stable --prefer-dist --no-interaction 109 | if [ "${{ matrix.db.driver }}" = "mysql" ]; then 110 | while ! mysqladmin ping --host=127.0.0.1 --user=cloudtasks --port=3307 --password=cloudtasks --silent; do 111 | echo "Waiting for MySQL..." 112 | sleep 1 113 | done 114 | else 115 | echo "Not waiting for MySQL." 116 | fi 117 | - name: Execute tests 118 | env: 119 | DB_DRIVER: ${{ matrix.db.driver }} 120 | DB_HOST: 127.0.0.1 121 | CI_CLOUD_TASKS_PROJECT_ID: ${{ secrets.CI_CLOUD_TASKS_PROJECT_ID }} 122 | CI_CLOUD_TASKS_QUEUE: ${{ secrets.CI_CLOUD_TASKS_QUEUE }} 123 | CI_CLOUD_TASKS_LOCATION: ${{ secrets.CI_CLOUD_TASKS_LOCATION }} 124 | CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL: ${{ secrets.CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL }} 125 | CI_SERVICE_ACCOUNT_JSON_KEY: ${{ secrets.CI_SERVICE_ACCOUNT_JSON_KEY }} 126 | CI_CLOUD_TASKS_CUSTOM_QUEUE: ${{ matrix.payload.queue }} 127 | run: | 128 | echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json 129 | touch .env 130 | vendor/bin/phpunit 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea/ 3 | .phpunit.result.cache 4 | .phpunit.cache 5 | .env 6 | /coverage -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stackkit (info@stackkit.io) 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 | # Cloud Tasks queue driver for Laravel 2 | 3 | [![Run tests](https://github.com/stackkit/laravel-google-cloud-tasks-queue/actions/workflows/run-tests.yml/badge.svg)](https://github.com/stackkit/laravel-google-cloud-tasks-queue/actions/workflows/run-tests.yml) 4 | Latest Stable Version 5 | Downloads 6 | 7 | This package allows Google Cloud Tasks to be used as the queue driver. 8 | 9 | Companion packages: Cloud Scheduler, Cloud Logging 10 | 11 | ![Image](https://github.com/user-attachments/assets/d9af0938-43b7-407b-8791-83419420a62b) 12 | 13 | 14 | 15 | ### Requirements 16 | 17 | This package requires Laravel 11 or 12. 18 | 19 | ### Installation 20 | 21 | Require the package using Composer 22 | 23 | ```shell 24 | composer require stackkit/laravel-google-cloud-tasks-queue 25 | ``` 26 | 27 | Add a new queue connection to `config/queue.php` 28 | 29 | ```php 30 | 'cloudtasks' => [ 31 | 'driver' => 'cloudtasks', 32 | 'project' => env('CLOUD_TASKS_PROJECT', ''), 33 | 'location' => env('CLOUD_TASKS_LOCATION', ''), 34 | 'queue' => env('CLOUD_TASKS_QUEUE', 'default'), 35 | 36 | // Required when using AppEngine 37 | 'app_engine' => env('APP_ENGINE_TASK', false), 38 | 'app_engine_service' => env('APP_ENGINE_SERVICE', ''), 39 | 40 | // Required when not using AppEngine 41 | 'handler' => env('CLOUD_TASKS_HANDLER', ''), 42 | 'service_account_email' => env('CLOUD_TASKS_SERVICE_EMAIL', ''), 43 | 44 | 'backoff' => 0, 45 | 'after_commit' => false, 46 | // enable this if you want to set a non-default Google Cloud Tasks dispatch timeout 47 | //'dispatch_deadline' => 1800, // in seconds 48 | ], 49 | ``` 50 | 51 | Finally, set the correct environment variables. 52 | 53 | ```dotenv 54 | QUEUE_CONNECTION=cloudtasks 55 | ``` 56 | 57 | If you're using Cloud Run: 58 | 59 | ```dotenv 60 | CLOUD_TASKS_PROJECT=my-project 61 | CLOUD_TASKS_LOCATION=europe-west6 62 | CLOUD_TASKS_QUEUE=barbequeue 63 | CLOUD_TASKS_SERVICE_EMAIL=my-service-account@appspot.gserviceaccount.com 64 | # Optionally (when using a separate task handler): 65 | CLOUD_TASKS_SERVICE_HANDLER= 66 | ``` 67 | 68 | If you're using App Engine: 69 | 70 | ```dotenv 71 | CLOUD_TASKS_PROJECT=my-project 72 | CLOUD_TASKS_LOCATION=europe-west6 73 | CLOUD_TASKS_QUEUE=barbequeue 74 | APP_ENGINE_TASK=true 75 | APP_ENGINE_SERVICE=my-service 76 | ``` 77 | 78 | Please check the table below on what the values mean and what their value should be. 79 | 80 | | Environment variable | Description | Example 81 | ---------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------- 82 | | `CLOUD_TASKS_PROJECT` | The project your queue belongs to. | `my-project` 83 | | `CLOUD_TASKS_LOCATION` | The region where the project is hosted. | `europe-west6` 84 | | `CLOUD_TASKS_QUEUE` | The default queue a job will be added to. | `emails` 85 | | **App Engine** 86 | | `APP_ENGINE_TASK` (optional) | Set to true to use App Engine task (else a Http task will be used). Defaults to false. | `true` 87 | | `APP_ENGINE_SERVICE` (optional) | The App Engine service to handle the task (only if using App Engine task). | `api` 88 | | **Non- App Engine apps** 89 | | `CLOUD_TASKS_SERVICE_EMAIL` (optional) | The email address of the service account. Important, it should have the correct roles. See the section below which roles. | `my-service-account@appspot.gserviceaccount.com` 90 | | `CLOUD_TASKS_HANDLER` (optional) | The URL that Cloud Tasks will call to process a job. This should be the URL to your Laravel app. By default we will use the URL that dispatched the job. | `https://.com` 91 | 92 | 93 | 94 | Optionally, you may publish the config file: 95 | 96 | ```console 97 | php artisan vendor:publish --tag=cloud-tasks 98 | ``` 99 | 100 | If you are using separate services for dispatching and handling tasks, and your application only dispatches jobs and should not be able to handle jobs, you may disable the task handler from `config/cloud-tasks.php`: 101 | 102 | ```php 103 | 'disable_task_handler' => env('CLOUD_TASKS_DISABLE_TASK_HANDLER', false), 104 | ``` 105 | 106 | ### How to 107 | 108 | #### Passing headers to a task 109 | 110 | You can pass headers to a task by using the `setTaskHeadersUsing` method on the `CloudTasksQueue` class. 111 | 112 | ```php 113 | use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; 114 | 115 | CloudTasksQueue::setTaskHeadersUsing(static fn() => [ 116 | 'X-My-Header' => 'My-Value', 117 | ]); 118 | ``` 119 | 120 | If necessary, the current payload being dispatched is also available: 121 | 122 | ```php 123 | use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; 124 | 125 | CloudTasksQueue::setTaskHeadersUsing(static fn(array $payload) => [ 126 | 'X-My-Header' => $payload['displayName'], 127 | ]); 128 | ``` 129 | 130 | #### Configure task handler url 131 | 132 | You can set the handler url for a task by using the `configureHandlerUrlUsing` method on the `CloudTasksQueue` class. 133 | 134 | ```php 135 | use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; 136 | 137 | CloudTasksQueue::configureHandlerUrlUsing(static fn() => 'https://example.com/my-url'); 138 | ``` 139 | 140 | If necessary, the current job being dispatched is also available: 141 | 142 | ```php 143 | use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; 144 | 145 | CloudTasksQueue::configureHandlerUrlUsing(static fn(MyJob $job) => 'https://example.com/my-url/' . $job->something()); 146 | ``` 147 | 148 | #### Configure worker options 149 | 150 | You can configure worker options by using the `configureWorkerOptionsUsing` method on the `CloudTasksQueue` class. 151 | 152 | ```php 153 | use Stackkit\LaravelGoogleCloudTasksQueue\IncomingTask; 154 | 155 | CloudTasksQueue::configureWorkerOptionsUsing(function (IncomingTask $task) { 156 | $queueTries = [ 157 | 'high' => 5, 158 | 'low' => 1, 159 | ]; 160 | 161 | return new WorkerOptions(maxTries: $queueTries[$task->queue()] ?? 1); 162 | }); 163 | ``` 164 | 165 | #### Use a custom credentials file 166 | 167 | Modify (or add) the `client_options` key in the `config/cloud-tasks.php` file: 168 | 169 | ```php 170 | 'client_options' => [ 171 | 'credentials' => '/path/to/credentials.json', 172 | ] 173 | ``` 174 | 175 | 176 | #### Modify CloudTasksClient options 177 | 178 | Modify (or add) the `client_options` key in the `config/cloud-tasks.php` file: 179 | 180 | ```php 181 | 'client_options' => [ 182 | // custom options here 183 | ] 184 | ``` 185 | 186 | ### How it works and differences 187 | 188 | Using Cloud Tasks as a Laravel queue driver is fundamentally different than other Laravel queue drivers, like Redis. 189 | 190 | Typically a Laravel queue has a worker that listens to incoming jobs using the `queue:work` / `queue:listen` command. 191 | With Cloud Tasks, this is not the case. Instead, Cloud Tasks will schedule the job for you and make an HTTP request to 192 | your application with the job payload. There is no need to run a `queue:work/listen` command. 193 | 194 | #### Good to know 195 | 196 | Cloud Tasks has it's own retry configuration options: maximum number of attempts, retry duration, min/max backoff and max doublings. All of these options are ignored by this package. Instead, you may configure max attempts, retry duration and backoff strategy right from Laravel. 197 | 198 | ### Authentication 199 | 200 | If you're not using your master service account (which has all abilities), you must add the following roles to make it 201 | works: 202 | 203 | 1. App Engine Viewer 204 | 2. Cloud Tasks Enqueuer 205 | 3. Cloud Tasks Viewer 206 | 4. Cloud Tasks Task Deleter 207 | 5. Service Account User 208 | 209 | ### Upgrading 210 | 211 | Read [UPGRADING.MD](UPGRADING.md) on how to update versions. 212 | 213 | ### Troubleshooting 214 | 215 | #### HttpRequest.url must start with 'https://' 216 | 217 | This can happen when your application runs behind a reverse proxy. To fix this, add the application domain to Laravel's [trusted proxies](https://laravel.com/docs/11.x/requests#trusting-all-proxies). You may need to add the wildcard `*` as trusted proxy. 218 | 219 | #### Maximum call stack size (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion? 220 | 221 | This currently seems to be a bug with PHP 8.3 and `googleapis/gax-php`. See [this issue](https://github.com/googleapis/gax-php/issues/584) for more information. 222 | 223 | A potential workaround is to disable PHP 8.3 call stack limit by setting this value in `php.ini`: 224 | 225 | ```ini 226 | zend.max_allowed_stack_size: -1 227 | ``` 228 | 229 | ### Contributing 230 | 231 | You can use the services defined in `docker-compose.yml` to start running the package. 232 | 233 | Inside the container, run `composer install`. 234 | 235 | Set up the environment: `cp .env.example .env` 236 | 237 | Some tests hit the Cloud Tasks API and need a project and key to be able to hit it. See the variables in `.env` 238 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # From 4.x to 5.x 2 | 3 | The package drops support for Laravel 10 and adds support for Laravel 12. 4 | 5 | ## Configuration type strictness (Impact: low) 6 | 7 | The package now uses `config()->string()` and `config()->array()` to enforce receiving the correct types from the Laravel configuration file. 8 | 9 | This should not give any problems but you should verify the configuration settings. 10 | 11 | There are no other breaking changes. 12 | 13 | # From 3.x to 4.x 14 | 15 | ## Renamed environment names (Impact: high) 16 | 17 | The following environment variables have been shortened: 18 | - `STACKKIT_CLOUD_TASKS_PROJECT` → `CLOUD_TASKS_PROJECT` 19 | - `STACKKIT_CLOUD_TASKS_LOCATION` → `CLOUD_TASKS_LOCATION` 20 | - `STACKKIT_CLOUD_TASKS_QUEUE` → `CLOUD_TASKS_QUEUE` 21 | - `STACKKIT_CLOUD_TASKS_HANDLER` → `CLOUD_TASKS_HANDLER` 22 | - `STACKKIT_CLOUD_TASKS_SERVICE_EMAIL` → `CLOUD_TASKS_SERVICE_EMAIL` 23 | 24 | The following environment variables have been renamed to be more consistent: 25 | 26 | - `STACKKIT_APP_ENGINE_TASK` → `CLOUD_TASKS_APP_ENGINE_TASK` 27 | - `STACKKIT_APP_ENGINE_SERVICE` → `CLOUD_TASKS_APP_ENGINE_SERVICE` 28 | 29 | The following environment variable has been removed: 30 | - `STACKKIT_CLOUD_TASKS_SIGNED_AUDIENCE` 31 | 32 | ## Removed dashboard (Impact: high) 33 | 34 | The dashboard has been removed to keep the package minimal. A separate composer package might be created with an updated version of the dashboard. 35 | 36 | ## New configuration file (Impact: medium) 37 | 38 | The configuration file has been updated to reflect the removed dashboard and to add new configurable options. 39 | 40 | Please publish the new configuration file: 41 | 42 | ```shell 43 | php artisan vendor:publish --tag=cloud-tasks --force 44 | ``` 45 | 46 | ## Dispatch deadline (Impact: medium) 47 | 48 | The `dispatch_deadline` has been removed from the task configuration. You may now use Laravel's timeout configuration to control the maximum execution time of a task. 49 | 50 | 51 | # From 2.x to 3.x 52 | 53 | PHP 7.2 and 7.3, and Laravel 5.x are no longer supported. 54 | 55 | ## Update handler URL (Impact: high) 56 | 57 | The handler URL environment has been simplified. Please change it like this: 58 | 59 | ```dotenv 60 | # Before 61 | STACKKIT_CLOUD_TASKS_HANDLER=https://my-app/handle-task 62 | # After 63 | STACKKIT_CLOUD_TASKS_HANDLER=https://my-app 64 | ``` 65 | 66 | It's also allowed to remove this variable entirely in 3.x: The package will automatically use the application URL if the `STACKKIT_CLOUD_TASKS_HANDLER` 67 | environment is not present. If you omit it, please ensure the [trusted proxy](https://laravel.com/docs/9.x/requests#configuring-trusted-proxies) have been configured 68 | in your application. Otherwise, you might run into weird issues. :-) 69 | -------------------------------------------------------------------------------- /app.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM serversideup/php:8.4-fpm 2 | 3 | USER root 4 | RUN install-php-extensions bcmath 5 | 6 | USER www-data -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackkit/laravel-google-cloud-tasks-queue", 3 | "description": "Google Cloud Tasks queue driver for Laravel", 4 | "keywords": ["laravel", "queue", "queues", "google", "cloudtasks", "cloud", "run"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Marick van Tuil", 9 | "email": "info@marickvantuil.nl" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "ext-json": "*", 15 | "google/cloud-tasks": "^2.0", 16 | "thecodingmachine/safe": "^3.0" 17 | }, 18 | "require-dev": { 19 | "orchestra/testbench": "^10.0", 20 | "thecodingmachine/phpstan-safe-rule": "^1.2", 21 | "laravel/pint": "^1.13", 22 | "larastan/larastan": "^3.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Stackkit\\LaravelGoogleCloudTasksQueue\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Tests\\": "tests/" 32 | } 33 | }, 34 | "extra": { 35 | "laravel": { 36 | "providers": [ 37 | "Stackkit\\LaravelGoogleCloudTasksQueue\\CloudTasksServiceProvider" 38 | ] 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true, 43 | "scripts": { 44 | "l11": [ 45 | "composer require laravel/framework:11.* orchestra/testbench:9.* --no-interaction --no-update", 46 | "composer update --prefer-stable --prefer-dist --no-interaction" 47 | ], 48 | "l12": [ 49 | "composer require laravel/framework:12.* orchestra/testbench:10.* --no-interaction --no-update", 50 | "composer update --prefer-stable --prefer-dist --no-interaction" 51 | ], 52 | "pint": [ 53 | "pint" 54 | ], 55 | "larastan": [ 56 | "@php -d memory_limit=-1 vendor/bin/phpstan" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /config/cloud-tasks.php: -------------------------------------------------------------------------------- 1 | env('CLOUD_TASKS_URI', 'handle-task'), 8 | 9 | // If the application only dispatches jobs 10 | 'disable_task_handler' => env('CLOUD_TASKS_DISABLE_TASK_HANDLER', false), 11 | 12 | // Optionally, pass custom options to the Cloud Tasks API client 13 | 'client_options' => [ 14 | // 'credentials' => '/path/to/custom/credentials.json', 15 | ], 16 | ]; 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: app.Dockerfile 6 | env_file: 7 | - .env 8 | volumes: 9 | - .:/var/www/html 10 | - ${CI_SERVICE_ACCOUNT_JSON_KEY_PATH-./tests/Support/gcloud-key-valid.json}:/var/www/html/tests/Support/gcloud-key-valid.json 11 | mysql: 12 | image: 'mysql:${MYSQL_VERSION:-8.0}' 13 | ports: 14 | - '${MYSQL_PORT:-3307}:3306' 15 | environment: 16 | MYSQL_USER: 'cloudtasks' 17 | MYSQL_PASSWORD: 'cloudtasks' 18 | MYSQL_DATABASE: 'cloudtasks' 19 | MYSQL_ROOT_PASSWORD: 'root' 20 | pgsql: 21 | image: 'postgres:${PGSQL_VERSION:-14}' 22 | ports: 23 | - '${POSTGRES_PORT:-5432}:5432' 24 | environment: 25 | POSTGRES_USER: 'cloudtasks' 26 | POSTGRES_PASSWORD: 'cloudtasks' 27 | POSTGRES_DB: 'cloudtasks' 28 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon 4 | parameters: 5 | paths: 6 | - src 7 | level: 9 8 | ignoreErrors: 9 | - "/dispatchAfterCommit with no type specified/" -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "fully_qualified_strict_types": true, 5 | "declare_strict_types": true, 6 | "ordered_imports": { 7 | "sort_algorithm": "length" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/CloudTasksApi.php: -------------------------------------------------------------------------------- 1 | client->createTask(new CreateTaskRequest([ 27 | 'parent' => $queueName, 28 | 'task' => $task, 29 | ])); 30 | } 31 | 32 | /** 33 | * @throws ApiException 34 | */ 35 | public function deleteTask(string $taskName): void 36 | { 37 | $this->client->deleteTask(new DeleteTaskRequest([ 38 | 'name' => $taskName, 39 | ])); 40 | } 41 | 42 | /** 43 | * @throws ApiException 44 | */ 45 | public function getTask(string $taskName): Task 46 | { 47 | return $this->client->getTask(new GetTaskRequest([ 48 | 'name' => $taskName, 49 | ])); 50 | } 51 | 52 | public function exists(string $taskName): bool 53 | { 54 | try { 55 | $this->getTask($taskName); 56 | 57 | return true; 58 | } catch (ApiException $e) { 59 | if ($e->getStatus() === 'NOT_FOUND') { 60 | return false; 61 | } 62 | 63 | report($e); 64 | } 65 | 66 | return false; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/CloudTasksApiContract.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public array $createdTasks = []; 20 | 21 | /** 22 | * @var array 23 | */ 24 | public array $deletedTasks = []; 25 | 26 | public function createTask(string $queueName, Task $task): Task 27 | { 28 | $this->createdTasks[] = compact('queueName', 'task'); 29 | 30 | return $task; 31 | } 32 | 33 | public function deleteTask(string $taskName): void 34 | { 35 | $this->deletedTasks[] = $taskName; 36 | } 37 | 38 | public function getTask(string $taskName): Task 39 | { 40 | return (new Task)->setName($taskName); 41 | } 42 | 43 | public function exists(string $taskName): bool 44 | { 45 | foreach ($this->createdTasks as $createdTask) { 46 | if ($createdTask['task']->getName() === $taskName) { 47 | return ! in_array($taskName, $this->deletedTasks); 48 | } 49 | } 50 | 51 | return false; 52 | } 53 | 54 | public function assertTaskDeleted(string $taskName): void 55 | { 56 | Assert::assertTrue( 57 | in_array($taskName, $this->deletedTasks), 58 | 'The task ['.$taskName.'] should have been deleted but it is not.' 59 | ); 60 | } 61 | 62 | public function assertTaskNotDeleted(string $taskName): void 63 | { 64 | Assert::assertTrue( 65 | ! in_array($taskName, $this->deletedTasks), 66 | 'The task ['.$taskName.'] should not have been deleted but it was.' 67 | ); 68 | } 69 | 70 | public function assertDeletedTaskCount(int $count): void 71 | { 72 | Assert::assertCount($count, $this->deletedTasks); 73 | } 74 | 75 | public function assertTaskCreated(Closure $closure): void 76 | { 77 | $count = count(array_filter($this->createdTasks, function ($createdTask) use ($closure) { 78 | return $closure($createdTask['task'], $createdTask['queueName']); 79 | })); 80 | 81 | Assert::assertTrue($count > 0, 'Task was not created.'); 82 | } 83 | 84 | public function assertCreatedTaskCount(int $count): void 85 | { 86 | Assert::assertCount($count, $this->createdTasks); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/CloudTasksConnector.php: -------------------------------------------------------------------------------- 1 | container = $container; 67 | $this->driver = $driver; 68 | $this->job = $job; 69 | $this->connectionName = $connectionName; 70 | $this->queue = $queue; 71 | } 72 | 73 | public function getJobId(): string 74 | { 75 | return $this->uuid() ?? throw new Exception; 76 | } 77 | 78 | /** 79 | * @throws JsonException 80 | */ 81 | public function getRawBody(): string 82 | { 83 | return json_encode($this->job); 84 | } 85 | 86 | public function attempts(): int 87 | { 88 | return $this->job['internal']['attempts'] ?? 0; 89 | } 90 | 91 | public function setAttempts(int $attempts): void 92 | { 93 | $this->job['internal']['attempts'] = $attempts; 94 | } 95 | 96 | public function delete(): void 97 | { 98 | // Laravel automatically calls delete() after a job is processed successfully. 99 | // However, this is not what we want to happen in Cloud Tasks because Cloud Tasks 100 | // will also delete the task upon a 200 OK status, which means a task is deleted twice. 101 | } 102 | 103 | public function release($delay = 0): void 104 | { 105 | parent::release($delay); 106 | 107 | $this->driver->release($this, $delay); 108 | 109 | if (! data_get($this->job, 'internal.errored')) { 110 | event(new JobReleased($this->getConnectionName(), $this, $delay)); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/CloudTasksQueue.php: -------------------------------------------------------------------------------- 1 | getQueueForJob($job); 123 | } 124 | 125 | if (is_object($job) && ! $job instanceof Closure) { 126 | /** @var JobBeforeDispatch $job */ 127 | $job->queue = $queue; 128 | } 129 | 130 | return $this->enqueueUsing( 131 | $job, 132 | $this->createPayload($job, $queue, $data), 133 | $queue, 134 | null, 135 | function ($payload, $queue) use ($job) { 136 | return $this->pushRaw($payload, $queue, ['job' => $job]); 137 | } 138 | ); 139 | } 140 | 141 | /** 142 | * Push a raw payload onto the queue. 143 | * 144 | * @param string $payload 145 | * @param string|null $queue 146 | * @param JobOptions $options 147 | * @return string 148 | */ 149 | public function pushRaw($payload, $queue = null, array $options = []) 150 | { 151 | $delay = ! empty($options['delay']) ? $options['delay'] : 0; 152 | $job = $options['job'] ?? null; 153 | 154 | return $this->pushToCloudTasks($queue, $payload, $delay, $job); 155 | } 156 | 157 | /** 158 | * Push a new job onto the queue after a delay. 159 | * 160 | * @param \DateTimeInterface|\DateInterval|int $delay 161 | * @param Closure|string|JobBeforeDispatch $job 162 | * @param mixed $data 163 | * @param string|null $queue 164 | * @return mixed 165 | */ 166 | public function later($delay, $job, $data = '', $queue = null) 167 | { 168 | // Laravel pls fix your typehints 169 | if (! $queue) { 170 | $queue = $this->getQueueForJob($job); 171 | } 172 | 173 | return $this->enqueueUsing( 174 | $job, 175 | $this->createPayload($job, $queue, $data), 176 | $queue, 177 | $delay, 178 | function ($payload, $queue, $delay) use ($job) { 179 | return $this->pushToCloudTasks($queue, $payload, $delay, $job); 180 | } 181 | ); 182 | } 183 | 184 | /** 185 | * Push a job to Cloud Tasks. 186 | * 187 | * @param string|null $queue 188 | * @param string $payload 189 | * @param \DateTimeInterface|\DateInterval|int $delay 190 | * @param Closure|string|object|null $job 191 | * @return string 192 | */ 193 | protected function pushToCloudTasks($queue, $payload, $delay, mixed $job) 194 | { 195 | $queue = $queue ?: $this->config['queue']; 196 | 197 | $payload = (array) json_decode($payload, true); 198 | 199 | /** @var JobShape $payload */ 200 | $task = tap(new Task)->setName($this->taskName($queue, $payload['displayName'])); 201 | 202 | $payload = $this->enrichPayloadWithAttempts($payload); 203 | 204 | $this->addPayloadToTask($payload, $task, $job); 205 | 206 | $availableAt = $this->availableAt($delay); 207 | if ($availableAt > time()) { 208 | $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); 209 | } 210 | 211 | $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); 212 | CloudTasksApi::createTask($queueName, $task); 213 | 214 | event(new TaskCreated($queue, $task)); 215 | 216 | return $payload['uuid']; 217 | } 218 | 219 | private function taskName(string $queueName, string $displayName): string 220 | { 221 | return CloudTasksClient::taskName( 222 | $this->config['project'], 223 | $this->config['location'], 224 | $queueName, 225 | str($displayName) 226 | ->afterLast('\\') 227 | ->replaceMatches('![^-\pL\pN\s]+!u', '-') 228 | ->replaceMatches('![-\s]+!u', '-') 229 | ->prepend((string) Str::ulid(), '-') 230 | ->toString(), 231 | ); 232 | } 233 | 234 | /** 235 | * @param JobShape $payload 236 | * @return JobShape 237 | */ 238 | private function enrichPayloadWithAttempts(array $payload): array 239 | { 240 | $payload['internal'] = [ 241 | 'attempts' => $payload['internal']['attempts'] ?? 0, 242 | ]; 243 | 244 | return $payload; 245 | } 246 | 247 | /** 248 | * @param Closure|string|object|null $job 249 | * @param JobShape $payload 250 | */ 251 | public function addPayloadToTask(array $payload, Task $task, $job): Task 252 | { 253 | $headers = $this->headers($payload); 254 | 255 | if (! empty($this->config['app_engine'])) { 256 | $path = \Safe\parse_url(route('cloud-tasks.handle-task'), PHP_URL_PATH); 257 | 258 | if (! is_string($path)) { 259 | throw new Exception('Something went wrong parsing the route.'); 260 | } 261 | 262 | $appEngineRequest = new AppEngineHttpRequest; 263 | $appEngineRequest->setRelativeUri($path); 264 | $appEngineRequest->setHttpMethod(HttpMethod::POST); 265 | $appEngineRequest->setBody(json_encode($payload)); 266 | $appEngineRequest->setHeaders($headers); 267 | 268 | if (! empty($this->config['app_engine_service'])) { 269 | $routing = new AppEngineRouting; 270 | $routing->setService($this->config['app_engine_service']); 271 | $appEngineRequest->setAppEngineRouting($routing); 272 | } 273 | 274 | $task->setAppEngineHttpRequest($appEngineRequest); 275 | } else { 276 | $httpRequest = new HttpRequest; 277 | $httpRequest->setUrl($this->getHandler($job)); 278 | $httpRequest->setBody(json_encode($payload)); 279 | $httpRequest->setHttpMethod(HttpMethod::POST); 280 | $httpRequest->setHeaders($headers); 281 | 282 | $token = new OidcToken; 283 | $token->setServiceAccountEmail($this->config['service_account_email'] ?? ''); 284 | $httpRequest->setOidcToken($token); 285 | $task->setHttpRequest($httpRequest); 286 | 287 | if (! empty($this->config['dispatch_deadline'])) { 288 | $task->setDispatchDeadline((new Duration)->setSeconds($this->config['dispatch_deadline'])); 289 | } 290 | } 291 | 292 | return $task; 293 | } 294 | 295 | public function pop($queue = null) 296 | { 297 | // It is not possible to pop a job from the queue. 298 | return null; 299 | } 300 | 301 | public function delete(CloudTasksJob $job): void 302 | { 303 | // Job deletion will be handled by Cloud Tasks. 304 | } 305 | 306 | public function release(CloudTasksJob $job, int $delay = 0): void 307 | { 308 | $this->pushRaw( 309 | payload: $job->getRawBody(), 310 | queue: $job->getQueue(), 311 | options: ['delay' => $delay, 'job' => $job], 312 | ); 313 | } 314 | 315 | /** 316 | * @param Closure|string|object|null $job 317 | */ 318 | public function getHandler(mixed $job): string 319 | { 320 | if (static::$handlerUrlCallback) { 321 | return (static::$handlerUrlCallback)($job); 322 | } 323 | 324 | if (empty($this->config['handler'])) { 325 | $this->config['handler'] = request()->getSchemeAndHttpHost(); 326 | } 327 | 328 | $handler = rtrim($this->config['handler'], '/'); 329 | 330 | if (str_ends_with($handler, '/'.config()->string('cloud-tasks.uri'))) { 331 | return $handler; 332 | } 333 | 334 | return $handler.'/'.config()->string('cloud-tasks.uri'); 335 | } 336 | 337 | /** 338 | * @param array $payload 339 | * @return array 340 | */ 341 | private function headers(mixed $payload): array 342 | { 343 | if (! static::$taskHeadersCallback) { 344 | return []; 345 | } 346 | 347 | return (static::$taskHeadersCallback)($payload); 348 | } 349 | 350 | /** 351 | * @param Closure|string|JobBeforeDispatch $job 352 | */ 353 | private function getQueueForJob(mixed $job): string 354 | { 355 | if (is_object($job) && ! $job instanceof Closure) { 356 | /** @var JobBeforeDispatch $job */ 357 | if (! empty($job->queue)) { 358 | return $job->queue; 359 | } 360 | } 361 | 362 | return $this->config['queue']; 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/CloudTasksServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerClient(); 23 | $this->registerConnector(); 24 | $this->registerConfig(); 25 | $this->registerRoutes(); 26 | $this->registerEvents(); 27 | } 28 | 29 | private function registerClient(): void 30 | { 31 | $this->app->singleton(CloudTasksClient::class, function () { 32 | return new CloudTasksClient(config()->array('cloud-tasks.client_options', [])); 33 | }); 34 | 35 | $this->app->singleton('cloud-tasks.worker', function (Application $app) { 36 | return new Worker( 37 | $app['queue'], 38 | $app['events'], 39 | $app[ExceptionHandler::class], 40 | fn () => $app->isDownForMaintenance(), 41 | ); 42 | }); 43 | 44 | $this->app->bind('cloud-tasks-api', CloudTasksApiConcrete::class); 45 | } 46 | 47 | private function registerConnector(): void 48 | { 49 | with(resolve('queue'), function (QueueManager $queue) { 50 | $queue->addConnector('cloudtasks', function () { 51 | return new CloudTasksConnector; 52 | }); 53 | }); 54 | } 55 | 56 | private function registerConfig(): void 57 | { 58 | $this->publishes([ 59 | __DIR__.'/../config/cloud-tasks.php' => config_path('cloud-tasks.php'), 60 | ], ['cloud-tasks']); 61 | 62 | $this->mergeConfigFrom(__DIR__.'/../config/cloud-tasks.php', 'cloud-tasks'); 63 | } 64 | 65 | private function registerRoutes(): void 66 | { 67 | if (config('cloud-tasks.disable_task_handler')) { 68 | return; 69 | } 70 | 71 | with(resolve('router'), function (Router $router) { 72 | $router->post(config()->string('cloud-tasks.uri'), [TaskHandler::class, 'handle']) 73 | ->name('cloud-tasks.handle-task'); 74 | }); 75 | } 76 | 77 | private function registerEvents(): void 78 | { 79 | /** @var Dispatcher $events */ 80 | $events = app('events'); 81 | 82 | $events->listen(JobFailed::class, function (JobFailed $event) { 83 | if (! $event->job instanceof CloudTasksJob) { 84 | return; 85 | } 86 | 87 | app('queue.failer')->log( 88 | $event->job->getConnectionName(), 89 | $event->job->getQueue(), 90 | $event->job->getRawBody(), 91 | $event->exception, 92 | ); 93 | }); 94 | 95 | $events->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { 96 | if (! $event->job instanceof CloudTasksJob) { 97 | return; 98 | } 99 | 100 | $event->job->job['internal']['errored'] = true; 101 | }); 102 | 103 | $events->listen(JobFailed::class, function ($event) { 104 | if (! $event->job instanceof CloudTasksJob) { 105 | return; 106 | } 107 | }); 108 | 109 | $events->listen(JobReleased::class, function (JobReleased $event) { 110 | if (! $event->job instanceof CloudTasksJob) { 111 | return; 112 | } 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Events/JobReleased.php: -------------------------------------------------------------------------------- 1 | command(); 54 | 55 | return $command['connection'] 56 | ?? config()->string('queue.default'); 57 | } 58 | 59 | public function queue(): string 60 | { 61 | $command = $this->command(); 62 | 63 | return $command['queue'] 64 | ?? config()->string('queue.connections.'.$this->connection().'.queue'); 65 | } 66 | 67 | public function shortTaskName(): string 68 | { 69 | return request()->header('X-CloudTasks-TaskName') 70 | ?? request()->header('X-AppEngine-TaskName') 71 | ?? throw new Error('Unable to extract taskname from header'); 72 | } 73 | 74 | public function fullyQualifiedTaskName(): string 75 | { 76 | /** @var QueueConfig $config */ 77 | $config = config('queue.connections.'.$this->connection()); 78 | 79 | return CloudTasksClient::taskName( 80 | project: $config['project'], 81 | location: $config['location'], 82 | queue: $this->queue(), 83 | task: $this->shortTaskName(), 84 | ); 85 | } 86 | 87 | /** 88 | * @return JobCommand 89 | */ 90 | public function command(): array 91 | { 92 | $command = $this->task['data']['command']; 93 | 94 | if (str_starts_with($command, 'O:')) { 95 | // @phpstan-ignore-next-line 96 | return (array) unserialize($command, ['allowed_classes' => false]); 97 | } 98 | 99 | if (app()->bound(Encrypter::class)) { 100 | // @phpstan-ignore-next-line 101 | return (array) unserialize(app(Encrypter::class)->decrypt($command)); 102 | } 103 | 104 | return []; 105 | } 106 | 107 | /** 108 | * @return JobShape 109 | */ 110 | public function toArray(): array 111 | { 112 | return $this->task; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/TaskHandler.php: -------------------------------------------------------------------------------- 1 | getContent()); 32 | } catch (Exception $e) { 33 | abort(422, $e->getMessage()); 34 | } 35 | 36 | event(new TaskIncoming($task)); 37 | 38 | if (! CloudTasksApi::exists($task->fullyQualifiedTaskName())) { 39 | abort(404); 40 | } 41 | 42 | /** @var QueueConfig $config */ 43 | $config = config('queue.connections.'.$task->connection()); 44 | 45 | $this->config = $config; 46 | 47 | // We want to catch any errors so we have more fine-grained control over 48 | // how tasks are retried. Cloud Tasks will retry the task if a 5xx status 49 | // is returned. Because we manually manage retries by releasing jobs, 50 | // we never want to return a 5xx status as that will result in duplicate 51 | // job attempts. 52 | rescue(fn () => $this->run($task)); 53 | } 54 | 55 | private function run(IncomingTask $task): void 56 | { 57 | $queue = tap(new CloudTasksQueue($this->config, $this->client))->setConnectionName($task->connection()); 58 | 59 | $job = new CloudTasksJob( 60 | container: Container::getInstance(), 61 | driver: $queue, 62 | job: $task->toArray(), 63 | connectionName: $task->connection(), 64 | queue: $task->queue(), 65 | ); 66 | 67 | $job->setAttempts($job->attempts() + 1); 68 | 69 | /** @var Worker $worker */ 70 | $worker = app('cloud-tasks.worker'); 71 | 72 | $worker->process( 73 | connectionName: $job->getConnectionName(), 74 | job: $job, 75 | options: CloudTasksQueue::getWorkerOptionsCallback() ? (CloudTasksQueue::getWorkerOptionsCallback())($task) : $this->getWorkerOptions() 76 | ); 77 | } 78 | 79 | public function getWorkerOptions(): WorkerOptions 80 | { 81 | $options = new WorkerOptions; 82 | 83 | if (isset($this->config['backoff'])) { 84 | $options->backoff = $this->config['backoff']; 85 | } 86 | 87 | return $options; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Worker.php: -------------------------------------------------------------------------------- 1 | timeoutForJob($job, $options), 0)); 34 | 35 | app(ExceptionHandler::class)->reportable( 36 | fn (FatalError $error) => $this->onFatalError($error, $job, $options) 37 | ); 38 | 39 | parent::process($connectionName, $job, $options); 40 | } 41 | 42 | private function onFatalError(FatalError $error, CloudTasksJob $job, WorkerOptions $options): bool 43 | { 44 | if (fnmatch('Maximum execution time * exceeded', $error->getMessage())) { 45 | $this->onJobTimedOut($job, $options); 46 | 47 | return false; 48 | } 49 | 50 | return true; 51 | } 52 | 53 | private function onJobTimedOut(CloudTasksJob $job, WorkerOptions $options): void 54 | { 55 | $this->markJobAsFailedIfWillExceedMaxAttempts( 56 | $job->getConnectionName(), $job, (int) $options->maxTries, $e = $this->timeoutExceededException($job) 57 | ); 58 | 59 | $this->markJobAsFailedIfWillExceedMaxExceptions( 60 | $job->getConnectionName(), $job, $e 61 | ); 62 | 63 | $this->markJobAsFailedIfItShouldFailOnTimeout( 64 | $job->getConnectionName(), $job, $e 65 | ); 66 | 67 | $this->events->dispatch(new JobTimedOut( 68 | $job->getConnectionName(), $job 69 | )); 70 | 71 | if (! $job->isDeleted() && ! $job->isReleased() && ! $job->hasFailed()) { 72 | $job->release($this->calculateBackoff($job, $options)); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/CloudTasksApiTest.php: -------------------------------------------------------------------------------- 1 | fail('Missing ['.$env.'] environment variable.'); 32 | } 33 | } 34 | 35 | $this->setConfigValue('project', env('CI_CLOUD_TASKS_PROJECT_ID')); 36 | $this->setConfigValue('queue', env('CI_CLOUD_TASKS_QUEUE')); 37 | $this->setConfigValue('location', env('CI_CLOUD_TASKS_LOCATION')); 38 | $this->setConfigValue('service_account_email', env('CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL')); 39 | 40 | $this->client = new CloudTasksClient; 41 | 42 | } 43 | 44 | #[Test] 45 | public function custom_client_options_can_be_added() 46 | { 47 | // Arrange 48 | config()->set('cloud-tasks.client_options', [ 49 | 'credentials' => __DIR__.'/Support/gcloud-key-dummy.json', 50 | ]); 51 | 52 | // Act 53 | $export = var_export(app(CloudTasksClient::class), true); 54 | 55 | // Assert 56 | 57 | // CloudTasksClient makes it a bit difficult to read its properties, so this will have to do... 58 | $this->assertStringContainsString('info@stackkit.io', $export); 59 | $this->assertStringContainsString('PRIVATE KEY', $export); 60 | } 61 | 62 | #[Test] 63 | public function test_create_task() 64 | { 65 | // Arrange 66 | $httpRequest = new HttpRequest; 67 | $httpRequest->setHttpMethod(HttpMethod::GET); 68 | $httpRequest->setUrl('https://example.com'); 69 | 70 | $cloudTask = new Task; 71 | $cloudTask->setHttpRequest($httpRequest); 72 | 73 | // Act 74 | $task = CloudTasksApi::createTask( 75 | $this->client->queueName( 76 | env('CI_CLOUD_TASKS_PROJECT_ID'), 77 | env('CI_CLOUD_TASKS_LOCATION'), 78 | env('CI_CLOUD_TASKS_QUEUE') 79 | ), 80 | $cloudTask 81 | ); 82 | $taskName = $task->getName(); 83 | 84 | // Assert 85 | $this->assertMatchesRegularExpression( 86 | '/projects\/'.env('CI_CLOUD_TASKS_PROJECT_ID').'\/locations\/'.env('CI_CLOUD_TASKS_LOCATION').'\/queues\/'.env('CI_CLOUD_TASKS_QUEUE').'\/tasks\/\d+$/', 87 | $taskName 88 | ); 89 | } 90 | 91 | #[Test] 92 | public function test_delete_task_on_non_existing_task() 93 | { 94 | // Assert 95 | $this->expectException(ApiException::class); 96 | $this->expectExceptionMessage('Requested entity was not found.'); 97 | 98 | // Act 99 | CloudTasksApi::deleteTask( 100 | $this->client->taskName( 101 | env('CI_CLOUD_TASKS_PROJECT_ID'), 102 | env('CI_CLOUD_TASKS_LOCATION'), 103 | env('CI_CLOUD_TASKS_QUEUE'), 104 | 'non-existing-id' 105 | ), 106 | ); 107 | 108 | } 109 | 110 | #[Test] 111 | public function test_delete_task() 112 | { 113 | // Arrange 114 | $httpRequest = new HttpRequest; 115 | $httpRequest->setHttpMethod(HttpMethod::GET); 116 | $httpRequest->setUrl('https://example.com'); 117 | 118 | $cloudTask = new Task; 119 | $cloudTask->setHttpRequest($httpRequest); 120 | $cloudTask->setScheduleTime(new Timestamp(['seconds' => time() + 10])); 121 | 122 | $task = CloudTasksApi::createTask( 123 | $this->client->queueName( 124 | env('CI_CLOUD_TASKS_PROJECT_ID'), 125 | env('CI_CLOUD_TASKS_LOCATION'), 126 | env('CI_CLOUD_TASKS_QUEUE') 127 | ), 128 | $cloudTask 129 | ); 130 | 131 | // Act 132 | $fresh = CloudTasksApi::getTask($task->getName()); 133 | $this->assertInstanceOf(Task::class, $fresh); 134 | 135 | CloudTasksApi::deleteTask($task->getName()); 136 | 137 | $this->expectException(ApiException::class); 138 | $this->expectExceptionMessage('NOT_FOUND'); 139 | CloudTasksApi::getTask($task->getName()); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/ConfigHandlerTest.php: -------------------------------------------------------------------------------- 1 | setConfigValue('handler', $handler); 21 | 22 | $this->dispatch(new SimpleJob); 23 | 24 | CloudTasksApi::assertTaskCreated(function (Task $task) use ($expectedHandler) { 25 | return $task->getHttpRequest()->getUrl() === $expectedHandler; 26 | }); 27 | } 28 | 29 | #[Test] 30 | public function the_handle_route_task_uri_can_be_configured(): void 31 | { 32 | CloudTasksApi::fake(); 33 | 34 | $this->app['config']->set('cloud-tasks.uri', 'my-custom-route'); 35 | 36 | $this->dispatch(new SimpleJob); 37 | 38 | CloudTasksApi::assertTaskCreated(function (Task $task) { 39 | return $task->getHttpRequest()->getUrl() === 'https://docker.for.mac.localhost:8080/my-custom-route'; 40 | }); 41 | } 42 | 43 | #[Test] 44 | public function the_handle_route_task_uri_in_combination_with_path_can_be_configured(): void 45 | { 46 | CloudTasksApi::fake(); 47 | 48 | $this->setConfigValue('handler', 'https://example.com/api'); 49 | $this->app['config']->set('cloud-tasks.uri', 'my-custom-route'); 50 | 51 | $this->dispatch(new SimpleJob); 52 | 53 | CloudTasksApi::assertTaskCreated(function (Task $task) { 54 | return $task->getHttpRequest()->getUrl() === 'https://example.com/api/my-custom-route'; 55 | }); 56 | } 57 | 58 | public static function handlerDataProvider(): array 59 | { 60 | return [ 61 | ['https://example.com', 'https://example.com/handle-task'], 62 | ['https://example.com/my/path', 'https://example.com/my/path/handle-task'], 63 | ['https://example.com/trailing/slashes//', 'https://example.com/trailing/slashes/handle-task'], 64 | ['https://example.com/handle-task', 'https://example.com/handle-task'], 65 | ['https://example.com/handle-task/', 'https://example.com/handle-task'], 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/IncomingTaskTest.php: -------------------------------------------------------------------------------- 1 | withTaskType($taskType); 37 | Str::createUlidsUsingSequence(['01HSR4V9QE2F4T0K8RBAYQ88KE']); 38 | 39 | // Act 40 | $this->dispatch(new $job)->run(); 41 | 42 | // Assert 43 | Event::assertDispatched(function (TaskIncoming $event) use ($job) { 44 | return $event->task->fullyQualifiedTaskName() === 'projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-'.class_basename($job) 45 | && $event->task->connection() === 'my-cloudtasks-connection' 46 | && $event->task->queue() === 'barbequeue'; 47 | }); 48 | } 49 | 50 | #[Test] 51 | #[TestWith([SimpleJob::class, 'cloudtasks'])] 52 | #[TestWith([SimpleJob::class, 'appengine'])] 53 | #[TestWith([EncryptedJob::class, 'cloudtasks'])] 54 | #[TestWith([EncryptedJob::class, 'appengine'])] 55 | public function it_reads_the_custom_queue(string $job, string $taskType) 56 | { 57 | // Arrange 58 | $this->withTaskType($taskType); 59 | 60 | // Act 61 | $this->dispatch((new $job)->onQueue('other-queue'))->run(); 62 | 63 | // Assert 64 | Event::assertDispatched(function (TaskIncoming $event) { 65 | return $event->task->queue() === 'other-queue'; 66 | }); 67 | } 68 | 69 | #[Test] 70 | #[TestWith([SimpleJob::class, 'cloudtasks'])] 71 | #[TestWith([SimpleJob::class, 'appengine'])] 72 | #[TestWith([EncryptedJob::class, 'cloudtasks'])] 73 | #[TestWith([EncryptedJob::class, 'appengine'])] 74 | public function it_reads_the_custom_connection(string $job, string $taskType) 75 | { 76 | // Arrange 77 | $this->withTaskType($taskType); 78 | 79 | // Act 80 | $this->dispatch((new $job)->onConnection('my-other-cloudtasks-connection'))->run(); 81 | 82 | // Assert 83 | Event::assertDispatched(function (TaskIncoming $event) { 84 | return $event->task->connection() === 'my-other-cloudtasks-connection' 85 | && $event->task->queue() === 'other-barbequeue'; 86 | }); 87 | } 88 | 89 | #[Test] 90 | #[TestWith([SimpleJob::class, 'cloudtasks'])] 91 | #[TestWith([SimpleJob::class, 'appengine'])] 92 | #[TestWith([EncryptedJob::class, 'cloudtasks'])] 93 | #[TestWith([EncryptedJob::class, 'appengine'])] 94 | public function it_reads_the_custom_connection_with_custom_queue(string $job, string $taskType) 95 | { 96 | // Arrange 97 | $this->withTaskType($taskType); 98 | 99 | // Act 100 | $this->dispatch( 101 | (new $job) 102 | ->onConnection('my-other-cloudtasks-connection') 103 | ->onQueue('custom-barbequeue') 104 | )->run(); 105 | 106 | // Assert 107 | Event::assertDispatched(function (TaskIncoming $event) { 108 | return $event->task->connection() === 'my-other-cloudtasks-connection' 109 | && $event->task->queue() === 'custom-barbequeue'; 110 | }); 111 | } 112 | 113 | #[Test] 114 | public function it_can_convert_the_incoming_task_to_array() 115 | { 116 | // Act 117 | $incomingTask = IncomingTask::fromJson('{"internal":{"connection":"my-other-cloudtasks-connection","queue":"custom-barbequeue","taskName":"projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-SimpleJob"}}'); 118 | 119 | // Act 120 | $array = $incomingTask->toArray(); 121 | 122 | // Assert 123 | $this->assertIsArray($array); 124 | $this->assertSame('my-other-cloudtasks-connection', $array['internal']['connection']); 125 | } 126 | 127 | #[Test] 128 | public function test_invalid_function() 129 | { 130 | // Assert 131 | $this->expectExceptionMessage('Invalid task payload.'); 132 | 133 | // Act 134 | IncomingTask::fromJson('{ invalid json }'); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tests/QueueAppEngineTest.php: -------------------------------------------------------------------------------- 1 | withTaskType('appengine'); 19 | } 20 | 21 | #[Test] 22 | public function an_app_engine_http_request_with_the_handler_url_is_made() 23 | { 24 | // Arrange 25 | CloudTasksApi::fake(); 26 | 27 | // Act 28 | $this->dispatch(new SimpleJob); 29 | 30 | // Assert 31 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 32 | return $task->getAppEngineHttpRequest()->getRelativeUri() === '/handle-task'; 33 | }); 34 | } 35 | 36 | #[Test] 37 | public function it_routes_to_the_service() 38 | { 39 | // Arrange 40 | CloudTasksApi::fake(); 41 | 42 | // Act 43 | $this->dispatch(new SimpleJob); 44 | 45 | // Assert 46 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 47 | return $task->getAppEngineHttpRequest()->getAppEngineRouting()->getService() === 'api'; 48 | }); 49 | } 50 | 51 | #[Test] 52 | public function it_contains_the_payload() 53 | { 54 | // Arrange 55 | CloudTasksApi::fake(); 56 | 57 | // Act 58 | $this->dispatch($job = new SimpleJob); 59 | 60 | // Assert 61 | CloudTasksApi::assertTaskCreated(function (Task $task) use ($job): bool { 62 | $decoded = json_decode($task->getAppEngineHttpRequest()->getBody(), true); 63 | 64 | return $decoded['displayName'] === SimpleJob::class 65 | && $decoded['job'] === 'Illuminate\Queue\CallQueuedHandler@call' 66 | && $decoded['data']['command'] === serialize($job); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/QueueTest.php: -------------------------------------------------------------------------------- 1 | dispatch(new SimpleJob); 54 | 55 | // Assert 56 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 57 | return $task->getHttpRequest()->getUrl() === 'https://docker.for.mac.localhost:8080/handle-task'; 58 | }); 59 | } 60 | 61 | #[Test] 62 | public function it_posts_to_the_handler() 63 | { 64 | // Arrange 65 | CloudTasksApi::fake(); 66 | 67 | // Act 68 | $this->dispatch(new SimpleJob); 69 | 70 | // Assert 71 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 72 | return $task->getHttpRequest()->getHttpMethod() === HttpMethod::POST; 73 | }); 74 | } 75 | 76 | #[Test] 77 | public function it_posts_to_the_configured_handler_url() 78 | { 79 | // Arrange 80 | $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8081'); 81 | CloudTasksApi::fake(); 82 | 83 | // Act 84 | $this->dispatch(new SimpleJob); 85 | 86 | // Assert 87 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 88 | return $task->getHttpRequest()->getUrl() === 'https://docker.for.mac.localhost:8081/handle-task'; 89 | }); 90 | } 91 | 92 | #[Test] 93 | public function it_posts_to_the_callback_handler_url() 94 | { 95 | // Arrange 96 | $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8081'); 97 | CloudTasksApi::fake(); 98 | CloudTasksQueue::configureHandlerUrlUsing(static fn (SimpleJob $job) => 'https://example.com/api/my-custom-route?job='.$job->id); 99 | 100 | // Act 101 | $job = new SimpleJob; 102 | $job->id = 1; 103 | $this->dispatch($job); 104 | 105 | // Assert 106 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 107 | return $task->getHttpRequest()->getUrl() === 'https://example.com/api/my-custom-route?job=1'; 108 | }); 109 | } 110 | 111 | #[Test] 112 | public function it_posts_the_serialized_job_payload_to_the_handler() 113 | { 114 | // Arrange 115 | CloudTasksApi::fake(); 116 | 117 | // Act 118 | $this->dispatch($job = new SimpleJob); 119 | 120 | // Assert 121 | CloudTasksApi::assertTaskCreated(function (Task $task) use ($job): bool { 122 | $decoded = json_decode($task->getHttpRequest()->getBody(), true); 123 | 124 | return $decoded['displayName'] === SimpleJob::class 125 | && $decoded['job'] === 'Illuminate\Queue\CallQueuedHandler@call' 126 | && $decoded['data']['command'] === serialize($job); 127 | }); 128 | } 129 | 130 | #[Test] 131 | public function it_will_set_the_scheduled_time_when_dispatching_later() 132 | { 133 | // Arrange 134 | CloudTasksApi::fake(); 135 | 136 | // Act 137 | $inFiveMinutes = now()->addMinutes(5); 138 | $this->dispatch((new SimpleJob)->delay($inFiveMinutes)); 139 | 140 | // Assert 141 | CloudTasksApi::assertTaskCreated(function (Task $task) use ($inFiveMinutes): bool { 142 | return $task->getScheduleTime()->getSeconds() === $inFiveMinutes->timestamp; 143 | }); 144 | } 145 | 146 | #[Test] 147 | public function it_posts_the_task_the_correct_queue() 148 | { 149 | // Arrange 150 | CloudTasksApi::fake(); 151 | 152 | $closure = fn () => 'closure job'; 153 | $closureDisplayName = CallQueuedClosure::create($closure)->displayName(); 154 | 155 | // Act 156 | $this->dispatch((new SimpleJob)); 157 | $this->dispatch((new FailingJob)->onQueue('my-special-queue')); 158 | $this->dispatch($closure); 159 | $this->dispatch($closure, 'my-special-queue'); 160 | 161 | // Assert 162 | CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { 163 | $decoded = json_decode($task->getHttpRequest()->getBody(), true); 164 | $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); 165 | 166 | return $decoded['displayName'] === SimpleJob::class 167 | && $command['queue'] === 'barbequeue' 168 | && $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; 169 | }); 170 | 171 | CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { 172 | $decoded = json_decode($task->getHttpRequest()->getBody(), true); 173 | $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); 174 | 175 | return $decoded['displayName'] === FailingJob::class 176 | && $command['queue'] === 'my-special-queue' 177 | && $queueName === 'projects/my-test-project/locations/europe-west6/queues/my-special-queue'; 178 | }); 179 | 180 | CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName) use ($closureDisplayName): bool { 181 | $decoded = json_decode($task->getHttpRequest()->getBody(), true); 182 | $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); 183 | 184 | return $decoded['displayName'] === $closureDisplayName 185 | && $command['queue'] === 'barbequeue' 186 | && $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; 187 | }); 188 | 189 | CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName) use ($closureDisplayName): bool { 190 | $decoded = json_decode($task->getHttpRequest()->getBody(), true); 191 | $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); 192 | 193 | return $decoded['displayName'] === $closureDisplayName 194 | && $command['queue'] === 'my-special-queue' 195 | && $queueName === 'projects/my-test-project/locations/europe-west6/queues/my-special-queue'; 196 | }); 197 | } 198 | 199 | #[Test] 200 | public function it_can_dispatch_after_commit_inline() 201 | { 202 | // Arrange 203 | CloudTasksApi::fake(); 204 | Event::fake(); 205 | 206 | // Act & Assert 207 | Event::assertNotDispatched(JobQueued::class); 208 | DB::beginTransaction(); 209 | SimpleJob::dispatch()->afterCommit(); 210 | Event::assertNotDispatched(JobQueued::class); 211 | while (DB::transactionLevel() !== 0) { 212 | DB::commit(); 213 | } 214 | Event::assertDispatched(JobQueued::class, function (JobQueued $event) { 215 | return $event->job instanceof SimpleJob; 216 | }); 217 | } 218 | 219 | #[Test] 220 | public function it_can_dispatch_after_commit_through_config() 221 | { 222 | // Arrange 223 | CloudTasksApi::fake(); 224 | Event::fake(); 225 | $this->setConfigValue('after_commit', true); 226 | 227 | // Act & Assert 228 | Event::assertNotDispatched(JobQueued::class); 229 | DB::beginTransaction(); 230 | SimpleJob::dispatch(); 231 | Event::assertNotDispatched(JobQueued::class); 232 | while (DB::transactionLevel() !== 0) { 233 | DB::commit(); 234 | } 235 | Event::assertDispatched(JobQueued::class, function (JobQueued $event) { 236 | return $event->job instanceof SimpleJob; 237 | }); 238 | } 239 | 240 | #[Test] 241 | public function jobs_can_be_released() 242 | { 243 | // Arrange 244 | CloudTasksApi::fake(); 245 | Event::fake([ 246 | JobReleasedAfterException::class, 247 | JobReleased::class, 248 | ]); 249 | 250 | // Act 251 | $this->dispatch(new JobThatWillBeReleased) 252 | ->runAndGetReleasedJob() 253 | ->run(); 254 | 255 | // Assert 256 | CloudTasksApi::assertTaskCreated(function (Task $task) { 257 | $body = $task->getHttpRequest()->getBody(); 258 | $decoded = json_decode($body, true); 259 | 260 | return $decoded['data']['commandName'] === 'Tests\\Support\\JobThatWillBeReleased' 261 | && $decoded['internal']['attempts'] === 1; 262 | }); 263 | 264 | CloudTasksApi::assertTaskCreated(function (Task $task) { 265 | $body = $task->getHttpRequest()->getBody(); 266 | $decoded = json_decode($body, true); 267 | 268 | return $decoded['data']['commandName'] === 'Tests\\Support\\JobThatWillBeReleased' 269 | && $decoded['internal']['attempts'] === 2; 270 | }); 271 | } 272 | 273 | #[Test] 274 | public function jobs_can_be_released_with_a_delay() 275 | { 276 | // Arrange 277 | CloudTasksApi::fake(); 278 | Event::fake([ 279 | JobReleasedAfterException::class, 280 | JobReleased::class, 281 | ]); 282 | Carbon::setTestNow(now()->addDay()); 283 | 284 | // Act 285 | $this->dispatch(new JobThatWillBeReleased(15))->run(); 286 | 287 | // Assert 288 | CloudTasksApi::assertTaskCreated(function (Task $task) { 289 | $body = $task->getHttpRequest()->getBody(); 290 | $decoded = json_decode($body, true); 291 | 292 | $scheduleTime = $task->getScheduleTime() ? $task->getScheduleTime()->getSeconds() : null; 293 | 294 | return $decoded['data']['commandName'] === 'Tests\\Support\\JobThatWillBeReleased' 295 | && $decoded['internal']['attempts'] === 1 296 | && $scheduleTime === now()->getTimestamp() + 15; 297 | }); 298 | } 299 | 300 | #[Test] 301 | public function test_default_backoff() 302 | { 303 | // Arrange 304 | CloudTasksApi::fake(); 305 | Event::fake(JobReleasedAfterException::class); 306 | 307 | // Act 308 | $this->dispatch(new FailingJob)->run(); 309 | 310 | // Assert 311 | CloudTasksApi::assertTaskCreated(function (Task $task) { 312 | return is_null($task->getScheduleTime()); 313 | }); 314 | } 315 | 316 | #[Test] 317 | public function test_backoff_from_queue_config() 318 | { 319 | // Arrange 320 | Carbon::setTestNow(now()->addDay()); 321 | $this->setConfigValue('backoff', 123); 322 | CloudTasksApi::fake(); 323 | Event::fake(JobReleasedAfterException::class); 324 | 325 | // Act 326 | $this->dispatch(new FailingJob)->run(); 327 | 328 | // Assert 329 | CloudTasksApi::assertTaskCreated(function (Task $task) { 330 | return $task->getScheduleTime() 331 | && $task->getScheduleTime()->getSeconds() === now()->getTimestamp() + 123; 332 | }); 333 | } 334 | 335 | #[Test] 336 | public function test_backoff_from_job() 337 | { 338 | // Arrange 339 | Carbon::setTestNow(now()->addDay()); 340 | CloudTasksApi::fake(); 341 | Event::fake(JobReleasedAfterException::class); 342 | 343 | // Act 344 | $failingJob = new FailingJob; 345 | $failingJob->backoff = 123; 346 | $this->dispatch($failingJob)->run(); 347 | 348 | // Assert 349 | CloudTasksApi::assertTaskCreated(function (Task $task) { 350 | return $task->getScheduleTime() 351 | && $task->getScheduleTime()->getSeconds() === now()->getTimestamp() + 123; 352 | }); 353 | } 354 | 355 | #[Test] 356 | public function test_exponential_backoff_from_job_method() 357 | { 358 | // Arrange 359 | Carbon::setTestNow(now()->addDay()); 360 | CloudTasksApi::fake(); 361 | 362 | // Act 363 | $releasedJob = $this->dispatch(new FailingJobWithExponentialBackoff) 364 | ->runAndGetReleasedJob(); 365 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 366 | $releasedJob->run(); 367 | 368 | // Assert 369 | CloudTasksApi::assertTaskCreated(function (Task $task) { 370 | return $task->getScheduleTime() 371 | && $task->getScheduleTime()->getSeconds() === now()->getTimestamp() + 50; 372 | }); 373 | CloudTasksApi::assertTaskCreated(function (Task $task) { 374 | return $task->getScheduleTime() 375 | && $task->getScheduleTime()->getSeconds() === now()->getTimestamp() + 60; 376 | }); 377 | CloudTasksApi::assertTaskCreated(function (Task $task) { 378 | return $task->getScheduleTime() 379 | && $task->getScheduleTime()->getSeconds() === now()->getTimestamp() + 70; 380 | }); 381 | } 382 | 383 | #[Test] 384 | public function test_failing_method_on_job() 385 | { 386 | // Arrange 387 | CloudTasksApi::fake(); 388 | Event::fake(JobOutput::class); 389 | 390 | // Act 391 | $this->dispatch(new FailingJob) 392 | ->runAndGetReleasedJob() 393 | ->runAndGetReleasedJob() 394 | ->runAndGetReleasedJob(); 395 | 396 | // Assert 397 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'FailingJob:failed'); 398 | } 399 | 400 | #[Test] 401 | public function test_queue_before_and_after_hooks() 402 | { 403 | // Arrange 404 | CloudTasksApi::fake(); 405 | Event::fake(JobOutput::class); 406 | 407 | // Act 408 | Queue::before(function (JobProcessing $event) { 409 | event(new JobOutput('Queue::before:'.$event->job->payload()['data']['commandName'])); 410 | }); 411 | Queue::after(function (JobProcessed $event) { 412 | event(new JobOutput('Queue::after:'.$event->job->payload()['data']['commandName'])); 413 | }); 414 | $this->dispatch(new SimpleJob)->run(); 415 | 416 | // Assert 417 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'Queue::before:Tests\Support\SimpleJob'); 418 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'Queue::after:Tests\Support\SimpleJob'); 419 | } 420 | 421 | #[Test] 422 | public function test_queue_looping_hook_not_supported_with_this_package() 423 | { 424 | // Arrange 425 | CloudTasksApi::fake(); 426 | Event::fake(JobOutput::class); 427 | 428 | // Act 429 | Queue::looping(function () { 430 | event(new JobOutput('Queue::looping')); 431 | }); 432 | $this->dispatch(new SimpleJob)->run(); 433 | 434 | // Assert 435 | Event::assertDispatchedTimes(JobOutput::class, times: 1); 436 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); 437 | } 438 | 439 | #[Test] 440 | public function test_ignoring_jobs_with_deleted_models() 441 | { 442 | // Arrange 443 | CloudTasksApi::fake(); 444 | Event::fake(JobOutput::class); 445 | 446 | $user1 = User::create([ 447 | 'name' => 'John', 448 | 'email' => 'johndoe@example.com', 449 | 'password' => bcrypt('test'), 450 | ]); 451 | 452 | $user2 = User::create([ 453 | 'name' => 'Jane', 454 | 'email' => 'janedoe@example.com', 455 | 'password' => bcrypt('test'), 456 | ]); 457 | 458 | // Act 459 | $this->dispatch(new UserJob($user1))->run(); 460 | 461 | $job = $this->dispatch(new UserJob($user2)); 462 | $user2->delete(); 463 | $job->run(); 464 | 465 | // Act 466 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'UserJob:John'); 467 | CloudTasksApi::assertTaskNotDeleted($job->task->getName()); 468 | } 469 | 470 | #[Test] 471 | public function it_adds_a_pre_defined_task_name() 472 | { 473 | // Arrange 474 | CloudTasksApi::fake(); 475 | Str::createUlidsUsingSequence(['01HSR4V9QE2F4T0K8RBAYQ88KE']); 476 | 477 | // Act 478 | $this->dispatch((new SimpleJob)); 479 | 480 | // Assert 481 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 482 | return $task->getName() === 'projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-SimpleJob'; 483 | }); 484 | } 485 | 486 | #[Test] 487 | public function headers_can_be_added_to_the_task() 488 | { 489 | // Arrange 490 | CloudTasksApi::fake(); 491 | 492 | // Act 493 | CloudTasksQueue::setTaskHeadersUsing(static fn () => [ 494 | 'X-MyHeader' => 'MyValue', 495 | ]); 496 | 497 | $this->dispatch((new SimpleJob)); 498 | 499 | // Assert 500 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 501 | return $task->getHttpRequest()->getHeaders()['X-MyHeader'] === 'MyValue'; 502 | }); 503 | } 504 | 505 | #[Test] 506 | public function headers_can_be_added_to_the_task_with_job_context() 507 | { 508 | // Arrange 509 | CloudTasksApi::fake(); 510 | 511 | // Act 512 | CloudTasksQueue::setTaskHeadersUsing(static fn (array $payload) => [ 513 | 'X-MyHeader' => $payload['displayName'], 514 | ]); 515 | 516 | $this->dispatch((new SimpleJob)); 517 | 518 | // Assert 519 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 520 | return $task->getHttpRequest()->getHeaders()['X-MyHeader'] === SimpleJob::class; 521 | }); 522 | } 523 | 524 | #[Test] 525 | public function batched_jobs_with_custom_queue_are_dispatched_on_the_custom_queue() 526 | { 527 | // Arrange 528 | CloudTasksApi::fake(); 529 | 530 | // Act 531 | $this->dispatch(Bus::batch([ 532 | tap(new SimpleJob, function (SimpleJob $job) { 533 | $job->queue = 'my-queue1'; 534 | }), 535 | tap(new SimpleJobWithTimeout, function (SimpleJob $job) { 536 | $job->queue = 'my-queue2'; 537 | }), 538 | ])->onQueue('my-batch-queue')); 539 | 540 | // Assert 541 | CloudTasksApi::assertCreatedTaskCount(2); 542 | 543 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 544 | return str_contains($task->getName(), 'SimpleJob') 545 | && str_contains($task->getName(), 'my-batch-queue'); 546 | }); 547 | 548 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 549 | return str_contains($task->getName(), 'SimpleJobWithTimeout') 550 | && str_contains($task->getName(), 'my-batch-queue'); 551 | }); 552 | } 553 | 554 | #[Test] 555 | public function it_can_dispatch_closures(): void 556 | { 557 | // Arrange 558 | CloudTasksApi::fake(); 559 | Event::fake(JobOutput::class); 560 | 561 | // Act 562 | $this->dispatch(function () { 563 | event(new JobOutput('ClosureJob:success')); 564 | })->run(); 565 | 566 | // Assert 567 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'ClosureJob:success'); 568 | } 569 | 570 | #[Test] 571 | public function task_has_no_dispatch_deadline_by_default(): void 572 | { 573 | // Arrange 574 | CloudTasksApi::fake(); 575 | 576 | // Act 577 | $this->dispatch(new SimpleJob); 578 | 579 | // Assert 580 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 581 | return $task->getDispatchDeadline() === null; 582 | }); 583 | } 584 | 585 | #[Test] 586 | public function task_has_no_dispatch_deadline_if_config_is_empty(): void 587 | { 588 | // Arrange 589 | CloudTasksApi::fake(); 590 | $this->setConfigValue('dispatch_deadline', null); 591 | 592 | // Act 593 | $this->dispatch(new SimpleJob); 594 | 595 | // Assert 596 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 597 | return $task->getDispatchDeadline() === null; 598 | }); 599 | } 600 | 601 | #[Test] 602 | public function task_has_configured_dispatch_deadline(): void 603 | { 604 | // Arrange 605 | CloudTasksApi::fake(); 606 | $this->setConfigValue('dispatch_deadline', 1800); 607 | 608 | // Act 609 | $this->dispatch(new SimpleJob); 610 | 611 | // Assert 612 | CloudTasksApi::assertTaskCreated(function (Task $task): bool { 613 | return $task->getDispatchDeadline()->getSeconds() === 1800; 614 | }); 615 | } 616 | } 617 | -------------------------------------------------------------------------------- /tests/Support/BaseJob.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 22 | $this->task = $task; 23 | $this->testCase = $testCase; 24 | } 25 | 26 | public function run(): void 27 | { 28 | $header = match (true) { 29 | $this->task->hasHttpRequest() => 'HTTP_X_CLOUDTASKS_TASKNAME', 30 | $this->task->hasAppEngineHttpRequest() => 'HTTP_X_APPENGINE_TASKNAME', 31 | default => throw new Error('Task does not have a request.'), 32 | }; 33 | 34 | $this->testCase->call( 35 | method: 'POST', 36 | uri: route('cloud-tasks.handle-task'), 37 | server: [ 38 | $header => (string) str($this->task->getName())->after('/tasks/'), 39 | ], 40 | content: $this->payload, 41 | ); 42 | } 43 | 44 | public function runAndGetReleasedJob(): self 45 | { 46 | $this->run(); 47 | 48 | $releasedTask = end($this->testCase->createdTasks); 49 | 50 | if (! $releasedTask) { 51 | $this->testCase->fail('No task was released.'); 52 | } 53 | 54 | $payload = $releasedTask->getAppEngineHttpRequest()?->getBody() 55 | ?: $releasedTask->getHttpRequest()->getBody(); 56 | 57 | return new self( 58 | $payload, 59 | $releasedTask, 60 | $this->testCase 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Support/EncryptedJob.php: -------------------------------------------------------------------------------- 1 | addMinutes(5); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Support/FailingJobWithNoMaxTries.php: -------------------------------------------------------------------------------- 1 | addMinutes(5); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Support/FailingJobWithUnlimitedTries.php: -------------------------------------------------------------------------------- 1 | release($this->releaseDelay); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Support/SimpleJob.php: -------------------------------------------------------------------------------- 1 | __FILE__, 17 | 'line' => __LINE__, 18 | ]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Support/User.php: -------------------------------------------------------------------------------- 1 | user = $user; 24 | } 25 | 26 | /** 27 | * Execute the job. 28 | * 29 | * @return void 30 | */ 31 | public function handle() 32 | { 33 | event(new JobOutput('UserJob:'.$this->user->name)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Support/gcloud-key-dummy.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "client_email": "info@stackkit.io", 4 | "private_key": "PRIVATE KEY" 5 | } 6 | -------------------------------------------------------------------------------- /tests/Support/gcloud-key-valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "client_email": "info@stackkit.io", 4 | "private_key": "PRIVATE KEY" 5 | } 6 | -------------------------------------------------------------------------------- /tests/TaskHandlerTest.php: -------------------------------------------------------------------------------- 1 | dispatch(new SimpleJob)->run(); 52 | 53 | // Assert 54 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); 55 | } 56 | 57 | #[Test] 58 | public function it_can_run_a_task_using_the_task_connection() 59 | { 60 | // Arrange 61 | 62 | Event::fake(JobOutput::class); 63 | $this->app['config']->set('queue.default', 'non-existing-connection'); 64 | 65 | // Act 66 | $job = new SimpleJob; 67 | $job->connection = 'my-cloudtasks-connection'; 68 | $this->dispatch($job)->run(); 69 | 70 | // Assert 71 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); 72 | } 73 | 74 | #[Test] 75 | public function after_max_attempts_it_will_log_to_failed_table() 76 | { 77 | // Arrange 78 | $job = $this->dispatch(new FailingJobWithMaxTries); 79 | 80 | // Act & Assert 81 | $this->assertDatabaseCount('failed_jobs', 0); 82 | 83 | $releasedJob = $job->runAndGetReleasedJob(); 84 | $this->assertDatabaseCount('failed_jobs', 0); 85 | 86 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 87 | $this->assertDatabaseCount('failed_jobs', 0); 88 | 89 | $releasedJob->run(); 90 | $this->assertDatabaseCount('failed_jobs', 1); 91 | } 92 | 93 | #[Test] 94 | public function uses_worker_options_callback_and_after_max_attempts_it_will_log_to_failed_table() 95 | { 96 | // Arrange 97 | CloudTasksQueue::configureWorkerOptionsUsing(function (IncomingTask $task) { 98 | $queueTries = [ 99 | 'high' => 5, 100 | 'low' => 1, 101 | ]; 102 | 103 | return new WorkerOptions(maxTries: $queueTries[$task->queue()] ?? 1); 104 | }); 105 | 106 | $job = $this->dispatch(tap(new FailingJobWithNoMaxTries, fn ($job) => $job->queue = 'high')); 107 | 108 | // Act & Assert 109 | $this->assertDatabaseCount('failed_jobs', 0); 110 | 111 | $releasedJob = $job->runAndGetReleasedJob(); 112 | $this->assertDatabaseCount('failed_jobs', 0); 113 | 114 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 115 | $this->assertDatabaseCount('failed_jobs', 0); 116 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 117 | $this->assertDatabaseCount('failed_jobs', 0); 118 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 119 | $this->assertDatabaseCount('failed_jobs', 0); 120 | 121 | $releasedJob->run(); 122 | $this->assertDatabaseCount('failed_jobs', 1); 123 | } 124 | 125 | #[Test] 126 | public function after_max_attempts_it_will_no_longer_execute_the_task() 127 | { 128 | // Arrange 129 | Event::fake([JobOutput::class]); 130 | $job = $this->dispatch(new FailingJob); 131 | 132 | // Act & Assert 133 | $releasedJob = $job->runAndGetReleasedJob(); 134 | Event::assertDispatched(JobOutput::class, 1); 135 | $this->assertDatabaseCount('failed_jobs', 0); 136 | 137 | $releasedJob = $releasedJob->runAndGetReleasedJob(); 138 | Event::assertDispatched(JobOutput::class, 2); 139 | $this->assertDatabaseCount('failed_jobs', 0); 140 | 141 | $releasedJob->run(); 142 | Event::assertDispatched(JobOutput::class, 4); 143 | $this->assertDatabaseCount('failed_jobs', 1); 144 | } 145 | 146 | #[Test] 147 | #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:00:00', 'should_fail' => false]])] 148 | #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:04:59', 'should_fail' => false]])] 149 | #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:05:00', 'should_fail' => true]])] 150 | public function after_max_retry_until_it_will_log_to_failed_table(array $args) 151 | { 152 | // Arrange 153 | $this->travelTo($args['now']); 154 | 155 | $job = $this->dispatch(new FailingJobWithRetryUntil); 156 | 157 | // Act 158 | $releasedJob = $job->runAndGetReleasedJob(); 159 | 160 | // Assert 161 | $this->assertDatabaseCount('failed_jobs', 0); 162 | 163 | // Act 164 | $this->travelTo($args['try_at']); 165 | $releasedJob->run(); 166 | 167 | // Assert 168 | $this->assertDatabaseCount('failed_jobs', $args['should_fail'] ? 1 : 0); 169 | } 170 | 171 | #[Test] 172 | public function test_unlimited_max_attempts() 173 | { 174 | // Assert 175 | Event::fake(JobOutput::class); 176 | 177 | // Act 178 | $job = $this->dispatch(new FailingJobWithUnlimitedTries); 179 | 180 | foreach (range(0, 50) as $attempt) { 181 | usleep(1000); 182 | $job = $job->runAndGetReleasedJob(); 183 | } 184 | 185 | Event::assertDispatched(JobOutput::class, 51); 186 | } 187 | 188 | #[Test] 189 | public function test_max_attempts_in_combination_with_retry_until() 190 | { 191 | // Arrange 192 | $this->travelTo('2020-01-01 00:00:00'); 193 | 194 | $job = $this->dispatch(new FailingJobWithMaxTriesAndRetryUntil); 195 | 196 | // When retryUntil is specified, the maxAttempts is ignored. 197 | 198 | // Act & Assert 199 | 200 | // The max attempts is 3, but the retryUntil is set to 5 minutes from now. 201 | // So when we attempt the job 4 times, it should still not fail. 202 | $job = $job 203 | ->runAndGetReleasedJob() 204 | ->runAndGetReleasedJob() 205 | ->runAndGetReleasedJob() 206 | ->runAndGetReleasedJob(); 207 | 208 | $this->assertDatabaseCount('failed_jobs', 0); 209 | 210 | // Now we travel to 5 minutes from now, and the job should fail. 211 | $this->travelTo('2020-01-01 00:05:00'); 212 | $job->run(); 213 | $this->assertDatabaseCount('failed_jobs', 1); 214 | } 215 | 216 | #[Test] 217 | public function it_can_handle_encrypted_jobs() 218 | { 219 | // Arrange 220 | Event::fake(JobOutput::class); 221 | 222 | // Act 223 | $job = $this->dispatch(new EncryptedJob); 224 | $job->run(); 225 | 226 | // Assert 227 | Event::assertDispatched(fn (JobOutput $event) => $event->output === 'EncryptedJob:success'); 228 | } 229 | 230 | #[Test] 231 | public function failing_jobs_are_released() 232 | { 233 | // Arrange 234 | Event::fake(JobReleasedAfterException::class); 235 | 236 | // Act 237 | $job = $this->dispatch(new FailingJob); 238 | 239 | CloudTasksApi::assertDeletedTaskCount(0); 240 | CloudTasksApi::assertCreatedTaskCount(1); 241 | CloudTasksApi::assertTaskNotDeleted($job->task->getName()); 242 | 243 | $job->run(); 244 | 245 | CloudTasksApi::assertCreatedTaskCount(2); 246 | Event::assertDispatched(JobReleasedAfterException::class, function ($event) { 247 | return $event->job->attempts() === 1; 248 | }); 249 | } 250 | 251 | #[Test] 252 | public function attempts_are_tracked_internally() 253 | { 254 | // Arrange 255 | Event::fake(JobReleasedAfterException::class); 256 | 257 | // Act & Assert 258 | $job = $this->dispatch(new FailingJob); 259 | 260 | $released = $job->runAndGetReleasedJob(); 261 | 262 | Event::assertDispatched(JobReleasedAfterException::class, function ($event) use (&$releasedJob) { 263 | $releasedJob = $event->job->getRawBody(); 264 | 265 | return $event->job->attempts() === 1; 266 | }); 267 | 268 | $released->run(); 269 | 270 | Event::assertDispatched(JobReleasedAfterException::class, function ($event) { 271 | return $event->job->attempts() === 2; 272 | }); 273 | } 274 | 275 | #[Test] 276 | public function retried_jobs_get_a_new_name() 277 | { 278 | // Arrange 279 | Event::fake(JobReleasedAfterException::class); 280 | CloudTasksApi::fake(); 281 | 282 | // Act & Assert 283 | $this->assertCount(0, $this->createdTasks); 284 | $this->dispatch(new FailingJob)->runAndGetReleasedJob(); 285 | $this->assertCount(2, $this->createdTasks); 286 | $this->assertNotEquals($this->createdTasks[0]->getName(), $this->createdTasks[1]->getName()); 287 | } 288 | 289 | #[Test] 290 | public function test_job_timeout() 291 | { 292 | // Arrange 293 | Event::fake(JobReleasedAfterException::class); 294 | 295 | // Act 296 | $this->dispatch(new SimpleJobWithTimeout)->run(); 297 | 298 | // Assert 299 | Event::assertDispatched(JobReleasedAfterException::class); 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | createdTasks[] = $event->task; 29 | }); 30 | } 31 | 32 | protected function getPackageProviders($app) 33 | { 34 | return [ 35 | CloudTasksServiceProvider::class, 36 | ]; 37 | } 38 | 39 | protected function defineDatabaseMigrations() 40 | { 41 | // Necessary to test the [failed_jobs] table. 42 | 43 | $this->loadMigrationsFrom(__DIR__.'/../vendor/orchestra/testbench-core/laravel/migrations'); 44 | $this->loadMigrationsFrom(__DIR__.'/../vendor/orchestra/testbench-core/laravel/migrations/queue'); 45 | } 46 | 47 | protected function getEnvironmentSetUp($app) 48 | { 49 | $app['config']->set('database.default', 'testbench'); 50 | $app['config']->set('queue.batching.database', 'testbench'); 51 | $port = env('DB_DRIVER') === 'mysql' ? 3307 : 5432; 52 | $app['config']->set('database.connections.testbench', [ 53 | 'driver' => env('DB_DRIVER', 'mysql'), 54 | 'host' => env('DB_HOST', 'mysql'), 55 | 'port' => env('DB_PORT', $port), 56 | 'database' => 'cloudtasks', 57 | 'username' => 'cloudtasks', 58 | 'password' => 'cloudtasks', 59 | 'prefix' => '', 60 | ]); 61 | 62 | $app['config']->set('cache.default', 'file'); 63 | $app['config']->set('queue.default', 'my-cloudtasks-connection'); 64 | $app['config']->set('queue.connections.my-cloudtasks-connection', [ 65 | 'driver' => 'cloudtasks', 66 | 'queue' => 'barbequeue', 67 | 'project' => 'my-test-project', 68 | 'location' => 'europe-west6', 69 | 'handler' => env('CLOUD_TASKS_HANDLER', 'https://docker.for.mac.localhost:8080'), 70 | 'service_account_email' => 'info@stackkit.io', 71 | ]); 72 | 73 | $app['config']->set('queue.connections.my-other-cloudtasks-connection', [ 74 | ...config('queue.connections.my-cloudtasks-connection'), 75 | 'queue' => 'other-barbequeue', 76 | 'project' => 'other-my-test-project', 77 | ]); 78 | 79 | $app['config']->set('queue.failed.driver', 'database-uuids'); 80 | $app['config']->set('queue.failed.database', 'testbench'); 81 | } 82 | 83 | protected function setConfigValue($key, $value) 84 | { 85 | $this->app['config']->set('queue.connections.my-cloudtasks-connection.'.$key, $value); 86 | } 87 | 88 | public function dispatch($job, ?string $onQueue = null): DispatchedJob 89 | { 90 | $payload = null; 91 | $task = null; 92 | 93 | Event::listen(TaskCreated::class, function (TaskCreated $event) use (&$payload, &$task) { 94 | $request = $event->task->getHttpRequest() ?? $event->task->getAppEngineHttpRequest(); 95 | $payload = $request->getBody(); 96 | $task = $event->task; 97 | }); 98 | 99 | if ($job instanceof PendingBatch) { 100 | if ($onQueue) { 101 | $job->onQueue($onQueue); 102 | } 103 | 104 | $job->dispatch(); 105 | } else { 106 | $pendingDispatch = dispatch($job); 107 | 108 | if ($onQueue) { 109 | $pendingDispatch->onQueue($onQueue); 110 | } 111 | 112 | unset($pendingDispatch); 113 | } 114 | 115 | return new DispatchedJob($payload, $task, $this); 116 | } 117 | 118 | public function withTaskType(string $taskType): void 119 | { 120 | switch ($taskType) { 121 | case 'appengine': 122 | $this->setConfigValue('handler', null); 123 | $this->setConfigValue('service_account_email', null); 124 | 125 | $this->setConfigValue('app_engine', true); 126 | $this->setConfigValue('app_engine_service', 'api'); 127 | break; 128 | case 'http': 129 | $this->setConfigValue('app_engine', false); 130 | $this->setConfigValue('app_engine_service', null); 131 | 132 | $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8080'); 133 | $this->setConfigValue('service_account_email', 'info@stackkit.io'); 134 | break; 135 | } 136 | } 137 | } 138 | --------------------------------------------------------------------------------