Skip to content

feat: throw when a failed job can't be stored instead of only logging it - #643

Merged
markshust merged 1 commit into
developfrom
feature/queue-failed-job-not-stored
Nov 1, 2026
Merged

markshust merged 1 commit into
developfrom
feature/queue-failed-job-not-stored

Conversation

@markshust

Copy link
Copy Markdown
Collaborator

Summary

When the failed-job repository can't store a job that failed for good, FailedJobHandler now throws a typed FailedJobNotStoredException (extends QueueException). The message names the job class and id. The context names the queue, the job's own exception and the storage error, and the storage error is attached as previous. #535's behaviour is unchanged: failed() isn't called and JobFailedEvent isn't dispatched when storing fails, and no caller deletes the job, so it stays in its queue (still reserved) and is failed again once its reservation expires.

How each caller handles it:

Caller Behaviour
Worker (last attempt, invalid backoff, retry limits, per-job timeout guard) Logs it at critical ([marko/queue] Worker stopping: <message> <context> <suggestion>), then rethrows it out of work(). queue:work prints it and exits 1.
Worker pop, when DatabaseQueue/RedisQueue fail a job at pop time Same as above. The worker no longer treats it as an ordinary pop failure (log, sleep, poll again). Every other pop failure still keeps polling.
Worker::handleTimeout() (inside the pcntl alarm handler) Logged at critical, not thrown, because an exception thrown from the signal handler would surface inside the interrupted job's handle(). The worker then exits with EXIT_TIMED_OUT, as it does after any timeout.
SyncQueue::push() Throws FailedJobNotStoredException instead of JobFailedException. This can only happen with a custom repository, because NullFailedJobRepository never throws.

Decisions

  • The worker stops instead of continuing. While the store is broken, every later final failure would fail the same way. Stopping makes the failure show up in the supervisor (exit 1, possibly a restart loop) rather than only in logs. Nothing is lost: the job stays reserved and is failed again after queue.retry_after, once the store works.
  • A dedicated subclass, FailedJobNotStoredException, with the named constructor failedJobNotStored(), rather than another factory on QueueException. With its own type, the worker can catch it separately from ordinary pop failures and other queue errors without matching on message text.
  • Timeout path logs instead of throwing (see the table). The process exits non-zero there anyway.
  • Only the repository store() call is wrapped. SerializationException from signing the payload still propagates as before.
  • Not marked breaking: no signatures change. Behaviour changes in two places: a pop-time storage failure now stops the worker where it used to be logged and polled past, and callers that caught the raw storage exception now get it as getPrevious().

Issue vs code

The issue says the handler "only logs" a storage failure. In fact FailedJobHandler never caught it, so the raw exception already propagated. The log-only handling was in the worker: its pop-failure path, which swallowed pop-time storage failures from the database/redis drivers and kept polling, and handleTimeout(). This PR fixes both places and types the exception.

Docs

queue.md: new "When a Failed Job Cannot Be Stored" subsection under "When a Job Fails for Good", plus updates to the pop-failure note, the Worker Logs table (new critical row), Stop Reasons, the FailedJobHandlerInterface reference and the Exceptions table. One-line notes were added to queue-database.md, queue-redis.md and queue-sync.md.

Tests

  • FailedJobHandlerTest: a store failure throws with the job class/id, queue, job error and storage error as previous. failed() isn't called, no event is dispatched, nothing is logged.
  • WorkerFailedJobNotStoredTest: the worker stops and pulls no further jobs; the job is neither deleted nor released; a critical log line is written; a FailedJobNotStoredException from pop() is rethrown with no sleep; other pop failures still poll; the timeout path logs critical and exits with EXIT_TIMED_OUT.
  • DatabaseQueueAttemptsTest: with failed_jobs renamed away, pop() throws, the row stays reserved, and failed() and the event don't run. After the table is restored and the reservation expires, the job is failed and stored, and failed() runs exactly once.
  • SyncQueueTest: push() throws FailedJobNotStoredException with the storage error as previous, and failed() and the event don't run.
  • composer ci green locally (tests, phpcs, php-cs-fixer, phpstan, docs:lint).

Fixes #641

🤖 Generated with Claude Code

https://claude.ai/code/session_017KaHtaJjfe2D38CdEdnmTb

…ored

FailedJobHandler now throws a typed FailedJobNotStoredException (a
QueueException naming the job class, id, queue and the job's own error,
with the storage error as previous) when the failed-job repository
throws. failed() and JobFailedEvent still don't run, and callers don't
delete the job, so it is failed again once its reservation expires.

The worker stops on it: it logs the exception at critical level and
rethrows it out of work() (queue:work exits 1), including when a driver
throws it from pop(), instead of logging a pop failure and polling
again. On the timeout path it is logged at critical level, since it
runs inside the alarm handler, and the worker exits as after any timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KaHtaJjfe2D38CdEdnmTb
@markshust markshust added the enhancement New feature or request label Nov 1, 2026
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Nov 1, 2026
@markshust
markshust merged commit a115953 into develop Nov 1, 2026
5 checks passed
@markshust
markshust deleted the feature/queue-failed-job-not-stored branch November 1, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Queue: throw when a failed job can't be stored instead of only logging it

1 participant