Skip to content

Queues and scheduling

The queue is Redis (QUEUE_CONNECTION=redis), run by Laravel Horizon. Start every lane with one command:

Terminal window
ddev artisan horizon

From config/horizon.php, the same in every environment:

Supervisor Queues, in order Processes Worker memory Timeout Tries
imports-and-default imports, then default 2 2048 MB 3600 s 1
media media 3 1024 MB 600 s 1

The split exists so that work people are waiting on never queues behind photos. imports comes before default on its supervisor, so an Add to catalog run is picked up first.

Things that must stay true when you change these numbers:

  • retry_after must outlast the longest timeout. The redis connection’s is 3700 seconds (config/queue.php, REDIS_QUEUE_RETRY_AFTER). If a job runs longer than retry_after, a second worker starts it too.
  • A job’s own $tries and $timeout beat the supervisor’s, but a timeout must still stay under retry_after.
  • Worker memory is a restart threshold. Horizon restarts a worker that passes it. An Add to catalog part on the largest feed peaks around 1.2 GB, which is why the first lane allows 2048 MB. The catalog’s photo pipeline uses ImageMagick, whose memory sits outside PHP’s limit, so check the server’s memory before adding media workers.
  • memory_limit (128 MB) is the Horizon master’s own. The master exits when it passes this limit, so a value below what it uses makes it restart forever.

The waits setting flags a queue as waiting too long: 300 seconds for imports and default, 900 for media.

The dashboard is at /horizon, behind Horizon::auth() and the admin.queues capability, which no role lists: super admins only, in every environment. The panel doesn’t link to it.

config/queue.php has a separate assistant connection with its own queue, a blocking pop and a 330-second retry_after. WebMoves\Assistant\Jobs\RunTurn runs there, so a chat turn starts at once. Horizon does not work this connection, so it needs a worker of its own. DDEV starts one (assistant-worker in .ddev/config.assistant.yaml), with one try: a failed turn is reported in the chat, not re-run.

Everything goes on default unless listed here. Spatie media library conversions follow MEDIA_QUEUE, set to media.

Job Queue Timeout Notes
Catalog\Jobs\AddToCatalog imports 3600 s One try; reports to Background tasks
Catalog\Jobs\DownloadSupplierMedia media 600 s Supplier photos and documents; unique
Catalog\Jobs\RunSupplierSync default 3600 s One per feed at a time, one try; a failed run is recorded, not retried
Catalog\Jobs\RefreshFromPrices default 1800 s One try; reports to Background tasks
Catalog\Jobs\ApplyDecorationSheets default 1800 s One try; reports to Background tasks
Catalog\Jobs\ClearSupplierStock default 1800 s One try
Catalog\Jobs\RegenerateProductPhotos default 900 s Remakes photo sizes, in chunks
Catalog\Jobs\RebuildProductSearch default 1800 s Unique
Catalog\Jobs\SyncDecorationMethodLinks default 600 s Unique
Catalog\Jobs\SyncProductSearch, SyncTermProductsSearch, PushProductSearchSettings default Supervisor’s Unique
ContentManagement\Sites\Jobs\CascadeReindexSubtreeJob, RebuildSiteDocumentsForTypeJob, IndexSiteDocumentJob default Supervisor’s Unique
ContentManagement\Sites\Jobs\ReindexSiteDocumentsForTargetsJob, RegenerateSiteMediaJob default Supervisor’s
ContentManagement\Bulk\BulkOperationJob default Supervisor’s One try: an operation may not be idempotent
ContentManagement\Widgets\Reviews\RefreshReviews default Supervisor’s
Automations\Jobs\RunAutomationRule default Supervisor’s Three tries
App\Jobs\UseUpscaledPhotos default 3600 s One try

Class names are under WebMoves\, apart from the App\ one. A few listeners and notifications also queue: SendQuoteRequestNotifications, NotifyRepOfProofDecision, ProofDecisionNotice and RuleNotice.

In tests the queue is sync, so these run inline unless the test calls Queue::fake().

There is no central schedule. Each module adds its entries in its service provider, with callAfterResolving(Schedule::class, ...). Production runs schedule:run every minute. Under DDEV nothing runs it unless you start ddev artisan schedule:work. ddev artisan schedule:list shows the current list. Times are in the app’s timezone (APP_TIMEZONE, UTC by default).

When Task Module What it does
Every minute automations:run-due automations Starts delayed automation rules that are due. Without overlapping
Every 5 minutes cms:publish-due content-management Reindexes content whose publish time has arrived. Without overlapping
Every 5 minutes catalog:run-due-syncs catalog Starts the feed sync that is due, one at a time. Without overlapping
Every 5 minutes horizon:snapshot admin Feeds Horizon’s graphs
Hourly cms:refresh-reviews content-management Fetches the reviews widgets’ reviews. Without overlapping
Hourly quote-cart:recover sales Marks stale carts abandoned and emails recovery nudges
Hourly inspire root Laravel’s stock example in routes/console.php. Does nothing useful
Daily 00:00 model:prune for BackgroundTask admin By Settings > Housekeeping
Daily 00:00 model:prune for ToolCall assistant The assistant’s tool-call log
Daily 00:00 model:prune for AutomationRun automations By Settings > Housekeeping
Daily 00:00 quote-cart:prune sales Hard-deletes anonymous abandoned carts and unattached artwork uploads
Daily 01:00 sales:fetch-exchange-rates sales Rates for the currencies sites use
Daily 03:30 catalog:prune-sync-inputs catalog Deletes old sync inputs by Settings > Housekeeping
Daily 03:45 activity log cleanup admin activitylog:clean by Settings > Housekeeping. Skipped when that setting is blank
Daily 06:00 sales:mark-overdue-invoices sales Stamps overdue invoices once and announces it

catalog:run-due-syncs decides from each feed’s own settings, in the timezone CATALOG_SYNC_TIMEZONE (Europe/London by default). See Syncing.

WebMoves\Admin\Models\BackgroundTask is how long-running work that a member of staff started reports back. It is what they see on System > Background tasks (guide), and it isn’t tied to the queue system. A job holds the task’s id and reports as it goes:

$task = BackgroundTask::start($user, 'catalog.add_to_catalog', 'Add 40 products to Pens', total: 40);
SomeJob::dispatch($task->id, ...); // and then, in the job:
$task->running();
$task->advance(); // one item done
$task->itemFailed('QS0345 DUO', 'No name'); // one item failed, counted as processed
$task->finish('38 products added, 2 failed'); // done
$task->fail($exception); // or the whole job failed

Finishing or failing sends whoever started it a database notification (the bell) with a link to the task. A task can have no user, in which case nobody is notified: catalog:add-supplier works that way. Done and failed tasks are pruned by age, queued and running ones never are. AddToCatalog, ApplyDecorationSheets and RefreshFromPrices use it today.

Staff see every task, and nobody can create, edit or delete one by hand (BackgroundTaskPolicy).

Use a background task when a person starts something and wants to know how it went. Don’t use it for system work no one is waiting on.