Exception Handling¶
v3 turns failures into data, not crashes. A handler may throw any Throwable; the runtime catches
it, records a failed ExecutionResult, and the worker decides whether to requeue with backoff or
abandon the message. The worker process itself keeps running.
Where exceptions are handled¶
Layer |
Class |
Responsibility |
|---|---|---|
Single attempt |
|
Run the handler, capture output, apply the timeout, turn any |
Retry decision |
|
Inspect the result; ack on success, |
Timeout |
|
Throw |
Key principle: exceptions never crash the worker. They are caught at the execution boundary, recorded, and routed through the same retry/abandon path as a logical failure.
Single attempt — JobRuntime¶
JobRuntime::run(JobDefinition $def, JobContext $ctx): ExecutionResult wraps the whole attempt:
try {
$handler = $this->registry->resolveForQueue($definition->handler, $context->queue ?? 'default');
} catch (Throwable $e) {
// Handler key unknown, or blocked by the per-queue allowlist.
return new ExecutionResult(false, null, $e->getMessage(), $start, microtime(true));
}
try {
$handler->beforeRun($context);
ob_start();
$returned = $this->timeout->run($timeoutSeconds, static fn () => $handler->handle($context), $label);
$result = new ExecutionResult(true, $this->normalizeOutput($returned, ob_get_clean()), null, $start, microtime(true), $handler::class);
$this->safeAfterRun($handler, $context, $result);
return $result;
} catch (Throwable $e) {
if (ob_get_level() > 0) {
ob_end_clean();
}
$result = new ExecutionResult(false, null, $e->getMessage(), $start, microtime(true), $handler::class);
$this->safeAfterRun($handler, $context, $result);
return $result;
}
What is caught:
Source |
Result |
|---|---|
Unknown handler key ( |
failed |
Handler not allowed on this queue |
failed |
|
failed |
Timeout exceeded |
failed |
Single-instance lock already held |
failed |
Any |
failed |
afterRun() runs through safeAfterRun(): an exception there is swallowed and never changes the
recorded outcome.
Retry decision — QueueWorker¶
The worker reads ExecutionResult::$success and acts:
success →
ack()failure,
attempts < maxRetries→nack($lease, $delay)(backend requeues with backoff)failure, retries exhausted →
abandon($lease)plus acriticallog line
abandon() is backend-specific, and “dead-letter” only describes one of them:
Backend |
|
|---|---|
Beanstalk |
buries the job — beanstalkd’s native dead-letter facility |
Redis |
drops the message from the processing list (no DLQ) |
Database |
marks the row |
The app-level Daycry\Jobs\Libraries\DeadLetterQueue helper is opt-in and is not called by
the worker: QueueWorker::processOnce() invokes $this->backend->abandon($lease) only. If you want a
DLQ on Redis/Database, wire DeadLetterQueue::store() yourself.
See Retries for the backoff model and Attempts for the counter.
Built-in exceptions¶
JobException (Daycry\Jobs\Exceptions\JobException)¶
Named constructors for definition/validation/execution errors:
JobException::forInvalidJob('unknown_handler');
JobException::validationError('CommandHandler payload must be a non-empty string.');
JobException::forShellCommandNotAllowed('/usr/bin/rm');
JobException::forShellCommandsNotConfigured(); // deny-by-default ShellHandler
JobException::forEventNotAllowed('user.deleted'); // not in $allowedEvents
JobException::forInvalidMethod('TRACE'); // UrlHandler method allowlist
JobException::forInvalidPriority(99);
JobException::forJobTimeout('app:report', 120);
JobException::forRateLimitExceeded('default', 50);
QueueException (Daycry\Jobs\Exceptions\QueueException)¶
Backend/queue resolution errors:
QueueException::forInvalidWorker('does-not-exist'); // unknown $backends key
QueueException::forInvalidQueue('nope');
QueueException::forInvalidConnection('redis refused');
QueueException::forInvalidWorker() is thrown by BackendFactory::make() when the requested backend
name is absent from Config\Jobs::$backends or does not implement QueueBackend.
Writing handlers¶
Throw freely — the runtime records the message and the worker handles retries. Prefer specific, descriptive exceptions:
use Daycry\Jobs\Handlers\AbstractJobHandler;
use Daycry\Jobs\Execution\JobContext;
use RuntimeException;
final class ImportUsers extends AbstractJobHandler
{
public function handle(JobContext $ctx): mixed
{
$payload = $ctx->payload;
if (! isset($payload['file'])) {
throw new RuntimeException('ImportUsers payload missing "file".');
}
// ... business logic ...
return 'imported';
}
}
Guidelines:
Make handlers idempotent (delivery is at-least-once).
Clean up resources with
try/finallyso a thrown exception still releases files/connections.Tune
maxRetriesper job: few for cheap work, more for flaky external calls,0to fail fast.
Running the worker under a supervisor¶
Exceptions inside a handler never stop the worker, but OOM, kill -9, or a backend outage can. Run
it under a process supervisor so it restarts:
[program:jobs-worker]
command=php spark jobs:queue:work default
autorestart=true
stopsignal=TERM
stderr_logfile=/var/log/jobs-worker.err.log
SIGTERM/SIGINT trigger a graceful shutdown: the current cycle finishes and the worker exits.