Skip to content

Commit 27b2560

Browse files
kieranbrownclaudetaylorotwell
authored
[13.x] Pop managed queue jobs from the cloud-agent instead of SQS (#60659)
* [13.x] Pop managed queue jobs from the cloud-agent instead of SQS Route the cloud Queue decorator's pop() through the in-container cloud-agent's unix-socket runtime API (GET /next) when the underlying connection is an SqsQueue, building an AgentSqsJob instead of receiving from SQS directly. Any non-SQS underlying queue is delegated to as before. The AgentSqsJob still deletes / releases against SQS through the inherited SqsClient — the agent never touches the message — but also reports each terminal outcome back to the agent via POST /result so it can stop heartbeating the message's visibility and accept the next invoke. The agent socket path is configurable via LARAVEL_CLOUD_AGENT_SOCKET, mirroring LARAVEL_CLOUD_LOG_SOCKET. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Report managed queue job outcome to agent instead of mutating SQS AgentSqsJob now calls the grandparent Job::delete()/release() to keep queue:work local state correct without SqsJob's direct SQS calls; the poller owns delete/visibility. reportResultToAgent now sends the release delay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Always pop from the agent and rename AgentSqsJob to CloudJob The cloud Queue always runs against a managed (SQS) queue, so pop() now polls the agent unconditionally instead of branching on the underlying queue type, inlining the former popFromAgent() helper. The agent socket path is hardcoded to /tmp/cloud-agent.sock, and AgentSqsJob is renamed to CloudJob to match the Cloud\Queue sibling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Always report processed on delete and strip unused error from result A terminally-failed managed queue job is deleted from SQS just like a successful one (the base fail() routes through delete()), so CloudJob now reports "processed" in both cases rather than a redelivery-triggering "failed". Drops the redundant $failing flag and the fail() override since the base markAsFailed() already runs before delete(). Also strips the unused $error field from the /result report path (the poller never reads it), and switches the agent transport from a raw Guzzle client to the Http facade so the pop loop can be exercised with Http::fake() in tests, matching how FailedJobProvider already talks to the cloud. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Harden CloudJob agent reporting and restore overflow support (#11) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Harden CloudJob overflow cleanup and agent reporting (#12) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Make CloudJob agent poller opt-in via config Default the managed-queue connection back to receiving jobs directly from SQS; route pops through the in-container cloud-agent only when agent.enabled is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Remove accidentally committed .phpactor.json Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Trust agent attributes and crash the worker on an unreachable agent Drop normalizeAgentAttributes: the dispatcher receives the message from SQS, which always returns ApproximateReceiveCount, so the agent forwards it and we read it straight through like SqsJob::attempts() trusts SQS. Stop rescuing requestNextFromAgent to null. An unreachable (or wedged) agent socket means the agent is not running in the pod, which is unrecoverable, so surface it as an AgentUnreachableException. An AgentAwareLostConnectionDetector (wired for queue:work) treats that as a lost connection, so the worker exits and the pod is restarted instead of idling as if the queue were empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Fall back to SQS directly when the cloud-agent is unreachable When reporting an in-flight job's outcome, distinguish an agent that is alive but rejecting the report (HTTP error: retain and let the retry / teardown safety net handle it, since its poller still owns the message) from an agent that has crashed (connection error: its poller can no longer act, so delete()/release() operate on SQS directly via SqsJob's real seams rather than lose the job). Receiving the next job from a crashed agent stays fatal; this only salvages a job already in flight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Read overflow config directly instead of via getOverflowStorage Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Remove stray whitespace in SqsQueue Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Own CloudJob delete/release instead of no-op overrides Skip SqsJob's SQS calls by flagging the job via the base Job directly, rather than overriding deleteMessageFromSqs/changeMessageVisibilityInSqs/ deleteOverflowPayload to no-ops. The delete/release logic now reads in one place, touching SQS only on the agent-unreachable fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Drop SQS fallback and teardown reporting from CloudJob PHP now only reports a job's outcome to the agent; it never mutates SQS itself. An agent that rejects a report has already finalized the job, so the message redelivers regardless. An unreachable agent propagates as an AgentUnreachableException — the worker treats it as a lost connection and exits (matching the pop side), and SQS redelivers via the visibility timeout once the crashed agent stops heartbeating. Removes CloudJob::fallBackToSqs() and reportToAgent(), and the teardown safety-net call in Queue::finishProcessingJob(). The WorkerStopping listener and shutdown function stay: they flush the terminal queue telemetry event for the last job of every worker run, which is separate from agent reporting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Make CloudJob report fire-and-trust, dropping report dedup report() no longer tracks reportedStatus or suppresses calls to fake idempotency / a "processed supersedes released" ordering. It reports each outcome as the worker produces it and assumes a returned report was accepted; the agent is the single authority on what ordering of operations is valid. A rejected report (RequestException) now propagates instead of being swallowed, so an outcome the agent cannot apply surfaces rather than being papered over. delete()'s overflow purge is therefore only reached once the agent accepts the "processed" outcome — a thrown report leaves the payload in place for the redelivery. Unreachable still raises AgentUnreachableException; the message redelivers via the visibility timeout regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Inline match into emitted queue event type Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Trim verbose comments to Laravel conventions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Escalate agent error and malformed responses as unreachable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Treat any non-200 from GET /next as agent unreachable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Use Response::ok() helper for the GET /next status check Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Escalate a server error from POST /result as agent unreachable Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Drop the pointless queue parameter from popFromAgent The agent only receives messages, so it has no queue to pop from; each message reports the SQS queue URL it came from. The queue parameter only fed a fallback that never fired, so remove it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Only pop from the agent for the worker's own queue The cloud-agent sidecar long-polls a single queue on behalf of one queue:work worker, so it only serves work when that worker is popping the queue it was started for. Gate the agent path on running queue:work and the requested queue matching the worker's --queue; anything else falls back to popping from SQS directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Document single-queue agent assumption and cover argv forms The cloud-agent serves a single queue, so a managed worker is expected to run one. Note this on workerQueue() and add tests pinning the space-separated --queue form and the multi-queue fallback to SQS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Report the receipt handle to the agent with each job outcome SQS reuses a message id across redeliveries, so a result matched on the id alone could let a stale outcome finalize a re-dispatched receive. Echo the receipt handle the agent supplied so it can pin the outcome to the exact receive; it is omitted when absent, preserving the id-only fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [13.x] Scope the agent result retry to connection failures Retry POST /result only on a ConnectionException, matching the method's docblock and FailedJobProvider::find(): a 4xx/5xx is the agent's authoritative response for the message, not a transient hiccup, so it propagates on the first attempt rather than being re-POSTed three times in the job-completion hot path. Also align workerQueue() with WorkCommand's --queue fallback so an option-less managed worker isn't silently misrouted off the agent, and firm up the test argv save/restore and the processed-event queue attribution coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * formatting --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent e2a7f29 commit 27b2560

7 files changed

Lines changed: 997 additions & 49 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Cloud;
4+
5+
use Illuminate\Contracts\Database\LostConnectionDetector;
6+
use Throwable;
7+
8+
/**
9+
* Treats an unreachable cloud-agent runtime socket as a lost connection so the
10+
* worker exits and the pod restarts — the only way to recover an agent that
11+
* has died in-pod. Every other exception is delegated to parent instance.
12+
*/
13+
class AgentAwareLostConnectionDetector implements LostConnectionDetector
14+
{
15+
/**
16+
* Create a new detector instance.
17+
*/
18+
public function __construct(
19+
protected LostConnectionDetector $detector,
20+
) {
21+
//
22+
}
23+
24+
/**
25+
* Determine if the given exception was caused by a lost connection.
26+
*/
27+
public function causedByLostConnection(Throwable $e): bool
28+
{
29+
return $e instanceof AgentUnreachableException
30+
|| $this->detector->causedByLostConnection($e);
31+
}
32+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Cloud;
4+
5+
use RuntimeException;
6+
7+
class AgentUnreachableException extends RuntimeException
8+
{
9+
//
10+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace Illuminate\Foundation\Cloud;
4+
5+
use Aws\Sqs\SqsClient;
6+
use Illuminate\Container\Container;
7+
use Illuminate\Queue\Jobs\Job;
8+
use Illuminate\Queue\Jobs\SqsJob;
9+
10+
class CloudJob extends SqsJob
11+
{
12+
/**
13+
* Create a new job instance.
14+
*
15+
* @param array $job
16+
* @param string $connectionName
17+
* @param string $queue
18+
* @param callable(string, int|null): void $reporter
19+
* @param array $overflowStorage
20+
*/
21+
public function __construct(
22+
Container $container,
23+
SqsClient $sqs,
24+
array $job,
25+
$connectionName,
26+
$queue,
27+
protected $reporter,
28+
array $overflowStorage = [],
29+
) {
30+
parent::__construct($container, $sqs, $job, $connectionName, $queue, $overflowStorage);
31+
}
32+
33+
/**
34+
* Delete the job from the queue.
35+
*
36+
* @return void
37+
*/
38+
public function delete()
39+
{
40+
// Skip SQS deletion so SQS DeleteMessage is left to the poller...
41+
Job::delete();
42+
43+
$this->report('processed');
44+
45+
// Only reached once the agent has accepted the outcome (report() throws otherwise)...
46+
$this->deleteOverflowPayload();
47+
}
48+
49+
/**
50+
* Release the job back into the queue after (n) seconds.
51+
*
52+
* @param int $delay
53+
* @return void
54+
*/
55+
public function release($delay = 0)
56+
{
57+
// Skip SQS deletion so SQS release is left to the poller...
58+
Job::release($delay);
59+
60+
$this->report('released', delay: $delay);
61+
}
62+
63+
/**
64+
* Report the job's outcome to the agent, which owns the SQS operation.
65+
*/
66+
protected function report(string $status, ?int $delay = null): void
67+
{
68+
($this->reporter)($status, $delay);
69+
}
70+
}

src/Illuminate/Foundation/Cloud/Queue.php

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
use Carbon\CarbonImmutable;
66
use Illuminate\Contracts\Queue\ClearableQueue;
77
use Illuminate\Contracts\Queue\Queue as QueueContract;
8+
use Illuminate\Foundation\Application;
9+
use Illuminate\Http\Client\ConnectionException;
10+
use Illuminate\Http\Client\RequestException;
11+
use Illuminate\Support\Facades\Http;
812
use Illuminate\Support\Str;
913
use Illuminate\Support\Traits\ForwardsCalls;
14+
use Symfony\Component\Console\Input\ArgvInput;
1015

1116
class Queue implements QueueContract, ClearableQueue
1217
{
@@ -37,6 +42,7 @@ class Queue implements QueueContract, ClearableQueue
3742
* Create a new Queue instance.
3843
*/
3944
public function __construct(
45+
protected Application $app,
4046
protected QueueContract $queue,
4147
protected Events $events,
4248
protected array $config,
@@ -185,20 +191,144 @@ public function bulk($jobs, $data = '', $queue = null)
185191
/**
186192
* Pop the next job off of the queue.
187193
*
194+
* Jobs come straight from SQS unless the cloud-agent is enabled.
195+
*
196+
* When cloud-agent is enabled, we long-poll the agent's runtime socket instead.
197+
*
188198
* @param string|null $queue
189199
* @return \Illuminate\Contracts\Queue\Job|null
190200
*/
191201
public function pop($queue = null)
192202
{
193203
$this->finishProcessingJob();
194204

195-
$job = $this->queue->pop(...func_get_args());
205+
$job = $this->usesAgent($queue)
206+
? $this->popFromAgent()
207+
: $this->queue->pop(...func_get_args());
196208

197209
$this->startProcessingJob($queue, $job);
198210

199211
return $job;
200212
}
201213

214+
/**
215+
* Long-poll the cloud-agent's runtime socket and wrap the next message in a CloudJob.
216+
*
217+
* @return \Illuminate\Foundation\Cloud\CloudJob|null
218+
*/
219+
protected function popFromAgent()
220+
{
221+
$data = $this->requestNextJobFromAgent();
222+
223+
if (! (is_array($data) && is_string($messageId = $data['messageId'] ?? null) && $messageId !== '')) {
224+
return null;
225+
}
226+
227+
// Coerce a non-string handle to null so a malformed response degrades gracefully...
228+
$receiptHandle = is_string($handle = $data['receiptHandle'] ?? null) ? $handle : null;
229+
230+
return new CloudJob(
231+
$this->queue->getContainer(),
232+
$this->queue->getSqs(),
233+
[
234+
'MessageId' => $messageId,
235+
'ReceiptHandle' => $receiptHandle,
236+
'Body' => is_string($body = $data['body'] ?? null) ? $body : '',
237+
'Attributes' => $data['attributes'] ?? [],
238+
],
239+
$this->queue->getConnectionName(),
240+
$data['queueUrl'] ?? null,
241+
fn (string $status, ?int $delay) => $this->reportJobStatusToAgent(
242+
$messageId, $receiptHandle, $status, $delay
243+
),
244+
$this->config['connection']['overflow'] ?? [],
245+
);
246+
}
247+
248+
/**
249+
* Long-poll the agent's runtime socket (GET /next) for the next job.
250+
*
251+
* @throws \Illuminate\Foundation\Cloud\AgentUnreachableException
252+
*/
253+
protected function requestNextJobFromAgent(): ?array
254+
{
255+
try {
256+
$response = $this->agentRequest()
257+
->timeout(65)
258+
->get('/next');
259+
} catch (ConnectionException $e) {
260+
throw new AgentUnreachableException(
261+
'The Laravel Cloud agent runtime socket is unreachable.', previous: $e
262+
);
263+
}
264+
265+
if ($response->status() === 204) {
266+
return null;
267+
}
268+
269+
if (! $response->ok()) {
270+
throw new AgentUnreachableException(
271+
"The Laravel Cloud agent returned HTTP {$response->status()} from GET /next."
272+
);
273+
}
274+
275+
if (! is_array($data = $response->json())) {
276+
throw new AgentUnreachableException(
277+
'The Laravel Cloud agent returned a non-array body from GET /next.'
278+
);
279+
}
280+
281+
return $data;
282+
}
283+
284+
/**
285+
* Report a job's terminal outcome back to the agent (POST /result). so it
286+
*
287+
* @throws \Illuminate\Http\Client\RequestException
288+
* @throws \Illuminate\Foundation\Cloud\AgentUnreachableException
289+
*/
290+
protected function reportJobStatusToAgent(string $messageId, ?string $receiptHandle, string $status, ?int $delay = null): void
291+
{
292+
try {
293+
$this->agentRequest()
294+
->timeout(10)
295+
->throw()
296+
->retry(3, 100, fn ($exception) => $exception instanceof ConnectionException)
297+
->post('/result', array_filter([
298+
'messageId' => $messageId,
299+
'receiptHandle' => $receiptHandle,
300+
'status' => $status,
301+
'delay' => $delay,
302+
], fn ($value) => $value !== null));
303+
} catch (ConnectionException $e) {
304+
throw new AgentUnreachableException(
305+
'The Laravel Cloud agent runtime socket is unreachable.', previous: $e
306+
);
307+
} catch (RequestException $e) {
308+
if ($e->response->serverError()) {
309+
throw new AgentUnreachableException(
310+
"The Laravel Cloud agent returned HTTP {$e->response->status()} from POST /result.", previous: $e
311+
);
312+
}
313+
314+
throw $e;
315+
}
316+
}
317+
318+
/**
319+
* Get a pending HTTP request bound to the agent's Unix runtime socket.
320+
*
321+
* @return \Illuminate\Http\Client\PendingRequest
322+
*/
323+
protected function agentRequest()
324+
{
325+
return Http::baseUrl('http://localhost')->withOptions([
326+
'curl' => [
327+
CURLOPT_UNIX_SOCKET_PATH => $this->config['agent']['socket'] ?? '/tmp/cloud-agent.sock',
328+
],
329+
]);
330+
}
331+
202332
/**
203333
* Delete all of the jobs from the queue.
204334
*
@@ -210,6 +340,29 @@ public function clear($queue)
210340
return $this->queue->clear(...func_get_args());
211341
}
212342

343+
/**
344+
* Determine whether the next job should be received from the in-container cloud-agent.
345+
*
346+
* @param string|null $queue
347+
*/
348+
protected function usesAgent($queue = null): bool
349+
{
350+
return ($this->config['agent']['enabled'] ?? false)
351+
&& $this->app->runningConsoleCommand('queue:work')
352+
&& $this->queue->getQueue($queue) === $this->queue->getQueue($this->workerQueue());
353+
}
354+
355+
/**
356+
* Get the queue the running queue:work worker is processing.
357+
*
358+
* @return string
359+
*/
360+
protected function workerQueue()
361+
{
362+
return (new ArgvInput)->getParameterOption('--queue')
363+
?: ($this->config['queue'] ?? 'default');
364+
}
365+
213366
/**
214367
* Get the connection name for the queue.
215368
*

src/Illuminate/Foundation/Cloud/QueueConnector.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Aws\CommandInterface;
66
use Aws\Exception\AwsException;
77
use Aws\Sqs\SqsClient;
8+
use Illuminate\Contracts\Database\LostConnectionDetector;
89
use Illuminate\Foundation\Application;
910
use Illuminate\Queue\Connectors\ConnectorInterface;
1011
use Illuminate\Queue\Events\JobQueued;
@@ -39,6 +40,7 @@ public function connect(array $config): Queue
3940
$underlying = $this->connector->connect($config['connection']);
4041

4142
$queue = new Queue(
43+
$this->app,
4244
$underlying,
4345
$this->app[Events::class],
4446
$config,
@@ -101,6 +103,12 @@ protected function configureWorker(Queue $queue): void
101103
Worker::$restartable = false;
102104
Worker::$pausable = false;
103105

106+
// Exit the worker (and restart the pod) when the agent socket is unreachable...
107+
$this->app->extend(
108+
LostConnectionDetector::class,
109+
fn ($detector) => new AgentAwareLostConnectionDetector($detector),
110+
);
111+
104112
$this->app['events']->listen(fn (WorkerStopping $event) => match ($event->reason) {
105113
WorkerStopReason::TimedOut => $queue->finishProcessingJob(default: 'released'),
106114
default => $queue->finishProcessingJob(),

src/Illuminate/Queue/Jobs/SqsJob.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ public function release($delay = 0)
6767
{
6868
parent::release($delay);
6969

70+
$this->changeMessageVisibilityInSqs($delay);
71+
}
72+
73+
/**
74+
* Reset the SQS message's visibility timeout so it becomes available again.
75+
*
76+
* @param int $delay
77+
* @return void
78+
*/
79+
protected function changeMessageVisibilityInSqs($delay)
80+
{
7081
$this->sqs->changeMessageVisibility([
7182
'QueueUrl' => $this->queue,
7283
'ReceiptHandle' => $this->job['ReceiptHandle'],
@@ -83,10 +94,30 @@ public function delete()
8394
{
8495
parent::delete();
8596

97+
$this->deleteMessageFromSqs();
98+
99+
$this->deleteOverflowPayload();
100+
}
101+
102+
/**
103+
* Delete the message from the SQS queue.
104+
*
105+
* @return void
106+
*/
107+
protected function deleteMessageFromSqs()
108+
{
86109
$this->sqs->deleteMessage([
87110
'QueueUrl' => $this->queue, 'ReceiptHandle' => $this->job['ReceiptHandle'],
88111
]);
112+
}
89113

114+
/**
115+
* Delete the offloaded overflow payload from the cache, if applicable.
116+
*
117+
* @return void
118+
*/
119+
protected function deleteOverflowPayload()
120+
{
90121
if (Arr::get($this->overflowStorage, 'delete_after_processing') &&
91122
$pointer = $this->overflowPointer()) {
92123
$this->overflowStore()->forget($pointer);

0 commit comments

Comments
 (0)