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

Daycry\Jobs\Execution\JobRuntime

Run the handler, capture output, apply the timeout, turn any Throwable into a failed ExecutionResult.

Retry decision

Daycry\Jobs\Worker\QueueWorker

Inspect the result; ack on success, nack(delay) with retries left, abandon($lease) (backend-specific) plus a critical log line when exhausted.

Timeout

Daycry\Jobs\Execution\Timeout

Throw JobException::forJobTimeout() at the deadline (SIGALRM, interrupts CPU-bound code) or via a soft post-hoc check without pcntl.

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 (forInvalidJob)

failed ExecutionResult

Handler not allowed on this queue

failed ExecutionResult

beforeRun() / handle() throws

failed ExecutionResult with the message

Timeout exceeded

failed ExecutionResult (JobException::forJobTimeout)

Single-instance lock already held

failed ExecutionResult ("single-instance job '...' is already running") — the worker requeues it (see Concurrency)

Any Throwable (Error, TypeError, …)

failed ExecutionResult

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 < maxRetriesnack($lease, $delay) (backend requeues with backoff)

  • failure, retries exhausted → abandon($lease) plus a critical log line

abandon() is backend-specific, and “dead-letter” only describes one of them:

Backend

abandon() behaviour

Beanstalk

buries the job — beanstalkd’s native dead-letter facility

Redis

drops the message from the processing list (no DLQ)

Database

marks the row failed (retained for audit, never re-fetched)

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/finally so a thrown exception still releases files/connections.

  • Tune maxRetries per job: few for cheap work, more for flaky external calls, 0 to 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.