├── .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 | [](https://github.com/stackkit/laravel-google-cloud-tasks-queue/actions/workflows/run-tests.yml)
4 |
5 |
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 | 
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 |
--------------------------------------------------------------------------------