Driver-based queue: Redis and memory drivers, priority, unique jobs, backoff, leases and crash recovery - #31
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The queue used to be hardwired to two database models. This PR puts a
QueueDrivercontract between the queue and its storage, shipsdatabase,redisandmemorydrivers on it, and adds the features that a driver-based design makes possible: job priority, unique jobs, retry backoff, leases with automatic crash recovery, multiple connections, bulk dispatch and queue statistics.Everything new is additive. Existing jobs,
#[Queueable], chains andqueue:runkeep working. The few behavior changes are listed under Breaking changes.Why
pop()was not atomic: two workers could claim the same job.What changed
Architecture
Contracts/QueueDriverdefines the guarantees every backend must give: atomic claim, priority then oldest first, leases, fencing, unique keys, plus the failed-job store.Drivers/DatabaseDriver,RedisDriver(Lua scripts, needspredis/predis), andMemoryDriver(for tests).QueueManagernow resolves connections fromruntime/config/queue.php(config/queue.phpin the package, merged by the launcher). Without a config file it uses thedatabaseconnection, as before.Queue::extend()registers custom drivers.Support\Envelope,Support\ReservedJob,Support\FailedJobRecord.QueueLauncherregisters the manager with a factory (the container cannot autowire its optional constructor arguments) and aliasesqueue.workerto it.QueueManager::classis also ghost-loadable.Features
onConnection(),$connection,#[Queueable(onConnection:)],Queue::connection('redis'), and--connectiononqueue:run,queue:failed,queue:retry,queue:flush,queue:monitor.withPriority(),$priority,#[Queueable(priority:)], range -100 to 100.queue:run --queue=high,default,lowdrains queues in order.uniqueId(). A duplicate of a waiting or running job is refused (push()returnsnull). Enforced by the backend, so it holds across workers.$backoff/#[Queueable(backoff:)], an int or a list indexed by attempt (last value repeats). Falls back to$retryAfter.lease, default 90s) expires, then another worker takes it with attempts kept. A job whose attempts are already used up is failed withMaxAttemptsExceededExceptioninstead of running again.delete,release,extendandfailonly succeed for the current claim (theattemptsvalue is the token), so a worker that lost its lease cannot remove or fail the job another worker now owns.Queue::pushMany(). Stats:Queue::stats()(ready, delayed, reserved) and a newqueue:monitortable.Queue::retryFailed(),Queue::extendLease(),QueueWorker::runNextJob(),setConnection(),setLeaseRenewInterval().Database schema
The create migration now includes
priority,lease_expires_atandunique_key(unique index). There is intentionally no upgrade migration: 4.1.0 is released as a separate version. Existing installs that already createdqueue_jobsneed those three columns added; the docs include a ready-made migration.Breaking changes
Queue::pop()returned theQueueJobmodelReservedJob($queue,$payload,$attempts). No model methods; useQueue::delete/release/markAsFailed.pop(),delete(),release(),markAsFailed()swallowed backend errors (null/false)push()/dispatch()always returned a job idnullwhen a unique job is refused.queue:monitorshowed Pending / ProcessingQueueJobandFailedJobmodels are still shipped for querying the tables, but the queue no longer uses them. Jobs that only implementJobInterfacekeep working (priority(),connection(),uniqueId()andbackoff()are optional).Bugs fixed on the way
InteractsWithModelSerializationserializedstaticproperties into the payload and wrote them back over the live value on unserialize.unserializeJob()emitted PHP warnings on a corrupt payload and accepted payloads that were not jobs; it now throwsQueueException.delayedis a reserved word in MySQL, which broke the stats query.base64:marker; ordinary payloads stay plain text.\0, invalid bytes become?), so recording a failure can never fail.queue:flush/queue:retrytake--id=; the docs showed a positional id.Testing
503 tests, PHPStan level 8 clean.
tests/Contract/QueueDriverContract): one suite run unchanged against Memory, SQLite, Redis, MySQL and PostgreSQL. Time is injected, so nothing sleeps.ConcurrentClaimTestraces 6 worker processes over 600 jobs on SQLite, Redis, MySQL and PostgreSQL and asserts each job is claimed exactly once; a second case kills a worker with SIGKILL and asserts the job returns after its lease.LeaseRenewalTestruns a slow job through the real timeout fork with a rival worker trying to steal it, plus a negative control with renewal off.tests.ymlnow starts MySQL 8, PostgreSQL 16 and Redis 7 and setsQUEUE_TEST_REQUIRE_BACKENDS=1, so a backend that is down fails the run instead of silently skipping its tests. Without that variable the suite still runs anywhere, skipping what it cannot reach.DROPand recreate the queue tables, so they refuse to run unless the database or schema name contains "test" or "scratch".Also verified by hand in a real application: dispatch to Redis and drain with
queue:run --connection=redis(priority order respected), and each queue command against the memory connection.