Skip to content

Commit d842772

Browse files
Merge pull request #187 from robertpcontreras-ts/feature/cloud-run-jobs
feat: Add Cloud Run Jobs support as queue target
2 parents d8298b6 + 684793b commit d842772

File tree

8 files changed

+996
-6
lines changed

8 files changed

+996
-6
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
.phpunit.result.cache
44
.phpunit.cache
55
.env
6-
/coverage
6+
/coverage
7+
.DS_Store

README.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,149 @@ If you're using separate services for dispatching and handling tasks, and your a
103103
'disable_task_handler' => env('CLOUD_TASKS_DISABLE_TASK_HANDLER', false),
104104
```
105105

106+
### Cloud Run Jobs
107+
108+
If you want jobs to be processed by Cloud Run Jobs instead of HTTP endpoints, you can configure the queue to trigger Cloud Run Job executions.
109+
110+
#### Why Cloud Run Jobs?
111+
112+
Cloud Run Jobs are ideal for long-running batch processing that exceeds Cloud Tasks HTTP timeout limits.
113+
114+
Cloud Run Jobs can run for up to 7 days.
115+
116+
**Tip**: Use seperate queue connections with different targets, for low latency jobs, use HTTP targets, for longer running batch jobs use Cloud Run Jobs.
117+
118+
#### Setup
119+
120+
1. **Create a Cloud Run Job** with your Laravel application container, configured to run:
121+
122+
```bash
123+
php artisan cloud-tasks:work-job
124+
```
125+
126+
The command reads job data from environment variables passed to the Job by Cloud Run.
127+
128+
2. **Configure your queue connection**:
129+
130+
```php
131+
'cloudtasks' => [
132+
'driver' => 'cloudtasks',
133+
'project' => env('CLOUD_TASKS_PROJECT'),
134+
'location' => env('CLOUD_TASKS_LOCATION'),
135+
'queue' => env('CLOUD_TASKS_QUEUE', 'default'),
136+
137+
// Cloud Run Job configuration
138+
'cloud_run_job' => env('CLOUD_TASKS_USE_CLOUD_RUN_JOB', false),
139+
'cloud_run_job_name' => env('CLOUD_RUN_JOB_NAME'),
140+
'cloud_run_job_region' => env('CLOUD_RUN_JOB_REGION'), // defaults to location
141+
'service_account_email' => env('CLOUD_TASKS_SERVICE_EMAIL'),
142+
143+
// Optional: Store large payloads (>10KB) in filesystem
144+
'payload_disk' => env('CLOUD_TASKS_PAYLOAD_DISK'), // Laravel disk name
145+
'payload_prefix' => env('CLOUD_TASKS_PAYLOAD_PREFIX', 'cloud-tasks-payloads'),
146+
'payload_threshold' => env('CLOUD_TASKS_PAYLOAD_THRESHOLD', 10240), // bytes
147+
],
148+
```
149+
150+
> **Note**: The command reads `CLOUD_TASKS_PAYLOAD`, `CLOUD_TASKS_TASK_NAME`, and `CLOUD_TASKS_PAYLOAD_PATH` directly from environment variables at runtime using `getenv()`. These are set automatically by Cloud Tasks via container overrides.
151+
152+
3. **Set environment variables**:
153+
154+
```dotenv
155+
CLOUD_TASKS_USE_CLOUD_RUN_JOB=true
156+
CLOUD_RUN_JOB_NAME=my-queue-worker-job
157+
CLOUD_RUN_JOB_REGION=europe-west1
158+
```
159+
160+
#### Large Payload Storage
161+
162+
For jobs with payloads exceeding environment variable limits (32KB limit enforced by Cloud Run), configure a Laravel filesystem disk:
163+
164+
```dotenv
165+
CLOUD_TASKS_PAYLOAD_DISK=gcs
166+
CLOUD_TASKS_PAYLOAD_PREFIX=cloud-tasks-payloads
167+
CLOUD_TASKS_PAYLOAD_THRESHOLD=30000
168+
```
169+
170+
When the payload exceeds the threshold, it's stored in the disk and `CLOUD_TASKS_PAYLOAD_PATH` is used instead.
171+
172+
> **Note**: The payloads will not be cleared up automatically, you can define lifecycle rules for the GCS bucket to delete old payloads.
173+
174+
#### How It Works
175+
176+
When you dispatch a job with Cloud Run Job target enabled:
177+
178+
1. Package creates a Cloud Task with HTTP target pointing to Cloud Run Jobs API
179+
2. Cloud Tasks calls `run.googleapis.com/v2/.../jobs/{job}:run`
180+
3. Cloud Run Jobs starts a new execution with environment variables set via container overrides:
181+
- `CLOUD_TASKS_PAYLOAD` - Base64-encoded job payload
182+
- `CLOUD_TASKS_TASK_NAME` - The task name
183+
4. The container runs `php artisan cloud-tasks:work-job` which reads the env vars and processes the job
184+
185+
All Laravel queue functionality is retained:
186+
- Job retries and max attempts
187+
- Failed job handling
188+
- Job timeouts
189+
- Encrypted jobs
190+
- Queue events
191+
192+
#### Required IAM Permissions
193+
194+
Cloud Run Jobs requires specific IAM permissions. Set these variables first:
195+
196+
```bash
197+
export PROJECT_ID="your-project-id"
198+
export SA_EMAIL="your-service-account@your-project-id.iam.gserviceaccount.com"
199+
export TASKS_AGENT="service-{PROJECT_NUMBER}@gcp-sa-cloudtasks.iam.gserviceaccount.com"
200+
```
201+
202+
> **Note**: Find your Cloud Tasks service agent email in the IAM console under "Include Google-provided role grants".
203+
> **Note**: Project ID and Project Number are different. Project ID is the name of your project, Project Number is the numeric ID of your project.
204+
205+
**Project-Level Permissions:**
206+
207+
```bash
208+
# Allow enqueuing tasks (required by PHP app running as $SA_EMAIL)
209+
gcloud projects add-iam-policy-binding $PROJECT_ID \
210+
--member="serviceAccount:$SA_EMAIL" \
211+
--role="roles/cloudtasks.enqueuer"
212+
213+
# Allow executing jobs with overrides (required for container overrides)
214+
gcloud projects add-iam-policy-binding $PROJECT_ID \
215+
--member="serviceAccount:$SA_EMAIL" \
216+
--role="roles/run.jobsExecutorWithOverrides"
217+
218+
# Allow invoking Cloud Run Services (if also using Cloud Run Services as HTTP targets)
219+
gcloud projects add-iam-policy-binding $PROJECT_ID \
220+
--member="serviceAccount:$SA_EMAIL" \
221+
--role="roles/run.invoker"
222+
```
223+
224+
**Note**: To restrict access to specific Cloud Run instances, use IAM conditions to limit access to specific Cloud Run Jobs / services.
225+
226+
**Service Account Permissions:**
227+
228+
```bash
229+
# Allow the SA to act as itself (required for task creation and execution)
230+
gcloud iam service-accounts add-iam-policy-binding $SA_EMAIL \
231+
--member="serviceAccount:$SA_EMAIL" \
232+
--role="roles/iam.serviceAccountUser"
233+
234+
# Allow Cloud Tasks to act as the SA (required for OAuth token generation)
235+
gcloud iam service-accounts add-iam-policy-binding $SA_EMAIL \
236+
--member="serviceAccount:$TASKS_AGENT" \
237+
--role="roles/iam.serviceAccountUser"
238+
```
239+
240+
| Permission | Required By | Purpose |
241+
|------------|-------------|---------|
242+
| `cloudtasks.enqueuer` | PHP App | Add tasks to the queue |
243+
| `cloudtasks.viewer` | Cloud Run Job | List queues/tasks (optional) |
244+
| `run.jobsExecutorWithOverrides` | Cloud Task | Execute jobs with container overrides |
245+
| `run.invoker` | Other Workloads | Invoke Cloud Run Services (if using HTTP targets) |
246+
| `iam.serviceAccountUser` (on SA) | Both | Allow SA to create tasks as itself |
247+
| `iam.serviceAccountUser` (Tasks Agent) | Google Infrastructure | Generate OAuth tokens for Cloud Run |
248+
106249
### How-To
107250

108251
#### Pass headers to a task

src/CloudTasksConnector.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
* service_account_email?: string,
2020
* backoff?: int,
2121
* dispatch_deadline?: int,
22-
* after_commit?: bool
22+
* after_commit?: bool,
23+
* cloud_run_job?: bool,
24+
* cloud_run_job_name?: string,
25+
* cloud_run_job_region?: string,
26+
* payload_disk?: string,
27+
* payload_prefix?: string,
28+
* payload_threshold?: int
2329
* }
2430
*/
2531
class CloudTasksConnector implements ConnectorInterface

src/CloudTasksQueue.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
use Illuminate\Queue\WorkerOptions;
1818
use Google\Cloud\Tasks\V2\OidcToken;
1919
use Google\Cloud\Tasks\V2\HttpMethod;
20+
use Google\Cloud\Tasks\V2\OAuthToken;
2021
use Google\Cloud\Tasks\V2\HttpRequest;
22+
use Illuminate\Support\Facades\Storage;
2123
use Google\Cloud\Tasks\V2\AppEngineRouting;
2224
use Illuminate\Queue\Queue as LaravelQueue;
2325
use Google\Cloud\Tasks\V2\AppEngineHttpRequest;
@@ -278,6 +280,45 @@ public function addPayloadToTask(array $payload, Task $task, $job): Task
278280
}
279281

280282
$task->setAppEngineHttpRequest($appEngineRequest);
283+
} elseif (! empty($this->config['cloud_run_job'])) {
284+
// Cloud Run Job target - call the Cloud Run Jobs execution API
285+
$httpRequest = new HttpRequest;
286+
$httpRequest->setUrl($this->getCloudRunJobExecutionUrl());
287+
$httpRequest->setHttpMethod(HttpMethod::POST);
288+
$httpRequest->setHeaders(array_merge($headers, [
289+
'Content-Type' => 'application/json',
290+
]));
291+
292+
// Build the execution request body with container overrides
293+
// The job payload is passed as environment variables
294+
$taskNameShort = str($task->getName())->afterLast('/')->toString();
295+
$encodedPayload = base64_encode(json_encode($payload));
296+
297+
// Build env vars for the container using fixed env var names
298+
// These map to config keys: cloud_run_job_payload, cloud_run_job_task_name, cloud_run_job_payload_path
299+
$envVars = $this->getCloudRunJobEnvVars($encodedPayload, $taskNameShort);
300+
301+
$executionBody = [
302+
'overrides' => [
303+
'containerOverrides' => [
304+
[
305+
'env' => $envVars,
306+
],
307+
],
308+
],
309+
];
310+
311+
$httpRequest->setBody(json_encode($executionBody));
312+
313+
$token = new OAuthToken;
314+
$token->setServiceAccountEmail($this->config['service_account_email'] ?? '');
315+
$token->setScope('https://www.googleapis.com/auth/cloud-platform');
316+
$httpRequest->setOAuthToken($token);
317+
$task->setHttpRequest($httpRequest);
318+
319+
if (! empty($this->config['dispatch_deadline'])) {
320+
$task->setDispatchDeadline((new Duration)->setSeconds($this->config['dispatch_deadline']));
321+
}
281322
} else {
282323
$httpRequest = new HttpRequest;
283324
$httpRequest->setUrl($this->getHandler($job));
@@ -367,4 +408,63 @@ private function getQueueForJob(mixed $job): string
367408

368409
return $this->config['queue'];
369410
}
411+
412+
/**
413+
* Get the Cloud Run Jobs execution API URL.
414+
*/
415+
private function getCloudRunJobExecutionUrl(): string
416+
{
417+
$project = $this->config['project'];
418+
$region = $this->config['cloud_run_job_region'] ?? $this->config['location'];
419+
$jobName = $this->config['cloud_run_job_name'] ?? throw new Exception('cloud_run_job_name is required when using Cloud Run Jobs.');
420+
421+
return sprintf(
422+
'https://run.googleapis.com/v2/projects/%s/locations/%s/jobs/%s:run',
423+
$project,
424+
$region,
425+
$jobName
426+
);
427+
}
428+
429+
/**
430+
* Get the environment variables for Cloud Run Job dispatch.
431+
*
432+
* If the payload exceeds the configured threshold, it will be stored
433+
* in the configured disk and the path will be returned instead.
434+
*
435+
* Env vars set map to config keys in the queue connection:
436+
* - CLOUD_TASKS_TASK_NAME -> cloud_run_job_task_name
437+
* - CLOUD_TASKS_PAYLOAD -> cloud_run_job_payload
438+
* - CLOUD_TASKS_PAYLOAD_PATH -> cloud_run_job_payload_path
439+
*
440+
* @return array<int, array{name: string, value: string}>
441+
*/
442+
private function getCloudRunJobEnvVars(string $encodedPayload, string $taskName): array
443+
{
444+
$disk = $this->config['payload_disk'] ?? null;
445+
$threshold = $this->config['payload_threshold'] ?? 10240; // 10KB default
446+
447+
$envVars = [
448+
['name' => 'CLOUD_TASKS_TASK_NAME', 'value' => $taskName],
449+
];
450+
451+
// If no disk configured or payload is below threshold, pass payload directly
452+
if ($disk === null || strlen($encodedPayload) <= $threshold) {
453+
$envVars[] = ['name' => 'CLOUD_TASKS_PAYLOAD', 'value' => $encodedPayload];
454+
455+
return $envVars;
456+
}
457+
458+
// Store payload in configured disk and pass path instead
459+
$prefix = $this->config['payload_prefix'] ?? 'cloud-tasks-payloads';
460+
$timestamp = now()->format('Y-m-d_H:i:s.v');
461+
$path = sprintf('%s/%s_%s.json', $prefix, $timestamp, $taskName);
462+
463+
Storage::disk($disk)->put($path, $encodedPayload);
464+
465+
// Set the path env var for large payloads
466+
$envVars[] = ['name' => 'CLOUD_TASKS_PAYLOAD_PATH', 'value' => $disk.':'.$path];
467+
468+
return $envVars;
469+
}
370470
}

src/CloudTasksServiceProvider.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Google\Cloud\Tasks\V2\Client\CloudTasksClient;
1515
use Stackkit\LaravelGoogleCloudTasksQueue\Events\JobReleased;
1616
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
17+
use Stackkit\LaravelGoogleCloudTasksQueue\Commands\WorkCloudRunJob;
1718

1819
class CloudTasksServiceProvider extends LaravelServiceProvider
1920
{
@@ -24,6 +25,7 @@ public function boot(): void
2425
$this->registerConfig();
2526
$this->registerRoutes();
2627
$this->registerEvents();
28+
$this->registerCommands();
2729
}
2830

2931
private function registerClient(): void
@@ -112,4 +114,13 @@ private function registerEvents(): void
112114
}
113115
});
114116
}
117+
118+
private function registerCommands(): void
119+
{
120+
if ($this->app->runningInConsole()) {
121+
$this->commands([
122+
WorkCloudRunJob::class,
123+
]);
124+
}
125+
}
115126
}

0 commit comments

Comments
 (0)