Laravel queues without a long-running worker

How Brewless runs Laravel queues on serverless containers. With Scaleway Queues a job reaches the worker the moment it is queued, with its tries and backoff.

In this post
  1. How does a Laravel queue work without a long-running worker?
  2. Where the jobs wait
  3. Scaleway Queues: the job comes to the worker
  4. Worker timeout and visibility
  5. The database queue
  6. What stays the same

On a server, Laravel's queue is a process that never ends: php artisan queue:work sits there and waits for jobs, and Supervisor restarts it when it dies. On serverless containers nothing stays running. There is no process to wait, and no Supervisor to keep one alive.

So on Brewless the worker does not wait for jobs. Jobs are brought to it. This post explains how, what Laravel features keep working, and the two settings in brewless.yml that matter.

How does a Laravel queue work without a long-running worker?

Something outside the container calls the worker when there is work. With Scaleway Queues, the queue itself calls the worker container as soon as a job is put on it, and the job runs in that request. With jobs in the database, a trigger calls the worker every minute to look for them.

Where the jobs wait

Where jobs wait is chosen per environment, with queue in brewless.yml:

brewless.yml
environments:
  production:
    region: fr-par
    database: shop-main
    queue: sqs
queue QUEUE_CONNECTION How the worker is called
sqs sqs By the queue at Scaleway Queues, as soon as a job is put on it
database database By a trigger, every minute
false sync Not at all: a job runs where it is dispatched

With sqs, Brewless switches Scaleway Queues on for your project, makes a queue named after the application and the environment, and gives it a key of its own that may send and receive but not make or remove queues. That key goes into your own Secret Manager, as SQS_ACCESS_KEY_ID and SQS_SECRET_ACCESS_KEY, because Laravel's sqs connection otherwise reads the same AWS_* names as the s3 disk. The brewless/laravel package hands them to the sqs connection.

Switching an environment from database to sqs in the file also moves the jobs that waited in the database to the new queue. The other way round is refused, because it would leave the queue behind with whatever is on it.

Note

Scaleway Queues speaks the SQS API, but not all of it. Sending and receiving jobs work; asking a queue how many jobs it holds, as Laravel's Queue::size() does, is refused. Queues is also not available in Warsaw or Milan yet.

Scaleway Queues: the job comes to the worker

Scaleway can attach a trigger to a queue: whenever a message arrives, it posts that message to a container. Brewless points that trigger at your worker container. So the moment your application dispatches a job, the queue posts it to the worker, and the worker runs it.

Nothing calls the worker between jobs. A quiet application has a worker that is scaled to zero, and, with a serverless database, a database that can sleep as well. When the next job arrives, the worker container starts first, so that job waits for a cold start. After that, it runs straight away.

This needs brewless/laravel 0.4 or later.

Signed jobs

The queue's trigger posts the job to / on the worker container without any secret of Brewless's. A job is code that gets unserialised, so the worker must not run whatever is posted to it.

brewless/laravel therefore signs every job that the sqs and database connections queue. The key is derived from APP_KEY, which every container of the application has, including the web container that dispatches most jobs. The worker runs a job only when its signature holds:

  • A job is good for an hour after it was signed (BREWLESS_JOB_MAX_AGE, 3600 seconds), and dropped after that.
  • Within that hour each delivery runs once. A delivery that comes again is dropped; one that another request is still handling is answered with a 409, so the queue tries it later.
  • After a rotation of APP_KEY, a job signed with a key in APP_PREVIOUS_KEYS still runs.

The marks that say a delivery ran are kept in the application's default cache. Give the containers a cache they share, the database or Redis, or a delivery that lands on another container is not recognised.

Tries, backoff and failed jobs

A delivered job runs the way queue:work runs it. Its $tries, its backoff(), its failed() method and the failed_jobs table all apply, so a job class you wrote for a server needs no change:

app/Jobs/SendInvoice.php
final class SendInvoice implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    /** @return list<int> */
    public function backoff(): array
    {
        return [10, 60, 300];
    }

    public function handle(): void
    {
        // ...
    }

    public function failed(Throwable $exception): void
    {
        // ...
    }
}

A job that is tried again goes back on its queue as a new message, signed again, and comes back to the worker after its backoff. A job that has used up its tries ends up in failed_jobs, where queue:work would put it.

The worker always answers the queue with a success, also for a job that failed or was dropped. Otherwise Scaleway would deliver the same message again every half minute, without end. Whether a job failed is your application's business, and it is in your own log and your own failed_jobs table.

Failed jobs can be put back on the queue from the Queues tab of the environment in the console. Why a job failed is in your application's failed_jobs table, and Brewless does not read it: an exception can quote the data it failed on.

Worker timeout and visibility

Two clocks matter for a job on Scaleway Queues.

The worker's timeout is how long one call to the worker container may take. It is 120 seconds by default and set in brewless.yml:

brewless.yml
environments:
  production:
    queue: sqs
    worker:
      timeout: 600
      memory: 1024

The visibility timeout is how long the queue keeps a job out of sight once it handed it to the worker. If the worker has not finished by then, the queue delivers the job again. Brewless sets it from the worker's timeout: three minutes, or the timeout plus half a minute when that is longer.

worker.timeout Visibility timeout
120 (default) 180 seconds
300 330 seconds
600 630 seconds

So the rule is simple: give a worker that runs long jobs a timeout above the longest job. The visibility follows on the next deploy, and a job that is still running is not handed out a second time.

Warning

A worker timeout below your longest job means that job is cut off when the container's time is up. Raise worker.timeout (up to 3600 seconds) or split the work into smaller jobs.

The database queue

Without Scaleway Queues, jobs wait in your database's jobs table. Then nothing can tell the worker a job arrived, so a trigger calls it every minute. Each call runs one tick of the scheduler and then works the queue until it is empty, for at most 45 seconds. A tick that finds no job due does not start a worker at all.

With jobs in the database, worker.queues names the queues the worker works, the first first:

brewless.yml
environments:
  production:
    queue: database
    worker:
      queues: [default, mail]

Without it, the worker works the default queue of your application. Taking queues out of the file again keeps what the worker works; write [default] to go back. This needs brewless/laravel 0.3 or later.

The database queue has two costs. A job waits up to a minute before it runs. And the worker, with its database, is called every minute all day, so a serverless database never gets to sleep. For an application with a serverless database, Scaleway Queues is the cheaper choice.

What stays the same

Your code dispatches jobs as it always did: SendInvoice::dispatch($invoice) and ->delay(...) go through Laravel's own queue connection. What changes is only who starts the worker.

What you leave behind is Supervisor, queue:restart after every deploy, and a process to watch. The worker moves to a new release right after the web container has switched to it. If a worker gives no sign of life for more than three minutes, the console says so.

Laravel's scheduler on Brewless is the other half of the worker container; Laravel's scheduler without a cron server covers it. The full reference is Queues and schedule.

Frequently asked questions

Can I run php artisan queue:work on Brewless?

Not as a process that stays up. Brewless runs Laravel on serverless containers, and queue:work is a command that never ends, so it is refused as a release command and as a one-off command. The worker container is called instead, either by the queue or by a trigger every minute.

How fast does a queued job run on Brewless?

With jobs on a queue at Scaleway Queues, the queue calls the worker as soon as a job is put on it. A worker that was scaled to zero starts first, so the first job after a quiet period waits for a cold start. With jobs in the database, a trigger calls the worker once a minute.

Do Laravel job tries, backoff and failed jobs still work?

Yes. The worker runs a delivered job the way queue:work does, with its tries, its backoff, its failed() method and the failed_jobs table. A job that is tried again goes back on its queue as a new message, and failed jobs can be put back on the queue from the Brewless console.

Should I use Scaleway Queues or the database queue?

Scaleway Queues runs a job right away and lets the worker and a serverless database sleep between jobs. The database queue needs no extra service, but a trigger then calls the worker every minute to look for jobs, which keeps the database awake all day. Scaleway Queues is not available in Warsaw or Milan yet.

Why are queued jobs signed?

The queue posts each job to the worker container without a secret, and a job is code that gets unserialised. brewless/laravel signs every job with a key derived from APP_KEY and runs only a job whose signature holds, within an hour of signing and once per delivery.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.