Queue System
The Volāre application implements a queue infrastructure for asynchronous tasks. The committed Laravel default is the database queue driver, and queue.php documents the production retry budget against the database driver; the SQS block remains present in config but has no retry_after knob and is not the source for the current timing guarantees.
Overview
Section titled “Overview”Key Features:
- Database-driven queues by default (
QUEUE_CONNECTION=database) - Automatic worker management via Docker Compose
- SQS config block present as an optional Laravel driver, but with no
retry_after; current retry-after invariants are documented against database/beanstalkd/redis - Built-in monitoring and debugging commands
- Graceful restarts and failure handling
Architecture
Section titled “Architecture”Components
Section titled “Components”Application Code -> Queue Connection -> PostgreSQL Jobs Table | v Queue Worker Container -> Job Handler -> Success/FailureInfrastructure
Section titled “Infrastructure”Runtime profile documented in code:
- Queue Driver:
databaseby default (backend/config/queue.php), with the database connection carryingDB_QUEUE_RETRY_AFTER - Worker Process: Dockerized queue worker (
queue/queue-flightslocally;queue-worker.shin production image) - Management: Docker Compose locally; container orchestration in production
Queue Priority
Section titled “Queue Priority”The main worker processes queues in priority order:
aerticket-tickets- Ticket issuance (highest priority)aerticket-fastlane- Fast-lane booking operationsaerticket-bookings- Booking creationaerticket-voids- Ticket void operationspipedrive-sync- Pipedrive CRM sync jobs (Lead/Booking/Client/Passenger/BookingUpsell/BookingNote/ProductByMarket/NewsletterSubscriber, plusAttachProductsToDealJob)activecampaign-sync- ActiveCampaign newsletter contact deliveryoffer-recalculations- Bulk offer flight/price recalculation sweepsoffer-generation- Auto-offer generation runs queued from the admin “Auto-Generate Offers” actions (GenerateAutoOffersJob), plus Last Seats campaign price syncs (SyncLastSeatsPricesJob)default- General tasks
pipedrive-sync and activecampaign-sync are listed before default so marketing/CRM sync jobs aren’t starved when default has a backlog. The Pipedrive queue name is configurable via the PIPEDRIVE_QUEUE env var (default pipedrive-sync, defined in backend/config/pipedrive.php); activecampaign-sync is a constant on the job (see ActiveCampaign Sync Queue).
flight-searches is not on the main worker — it runs on a dedicated queue-flights service (see Flight Search Worker) so a burst of customer-facing Aerticket searches can’t delay ticketing/booking jobs, and the main worker’s admin sweeps (offer-recalculations) can’t slow customer-driven cache fills.
Quick Start
Section titled “Quick Start”Dispatching Jobs
Section titled “Dispatching Jobs”use App\Jobs\TestQueueJob;
// Simple dispatchTestQueueJob::dispatch('Hello, Queue!');
// Delayed dispatch (5 minutes)TestQueueJob::dispatch('Delayed message') ->delay(now()->addMinutes(5));
// Custom queueTestQueueJob::dispatch('High priority') ->onQueue('high-priority');Monitoring
Section titled “Monitoring”# Check queue status./vendor/bin/sail artisan queue:monitor
# Watch logs in real-time./vendor/bin/sail artisan tail
# Check failed jobs./vendor/bin/sail artisan queue:failedConfiguration
Section titled “Configuration”Environment Variables
Section titled “Environment Variables”# Queue driver (committed default: database)QUEUE_CONNECTION=database
# Database queue settingsDB_QUEUE_CONNECTION=pgsqlDB_QUEUE_TABLE=jobsDB_QUEUE=defaultDB_QUEUE_RETRY_AFTER=240
# Job retry settingsQUEUE_MAX_ATTEMPTS=3Docker Compose Configuration
Section titled “Docker Compose Configuration”queue: build: context: './vendor/laravel/sail/runtimes/8.5' dockerfile: Dockerfile volumes: - '.:/var/www/html' networks: - sail depends_on: - pgsql - redis command: php artisan queue:work --queue=aerticket-tickets,aerticket-fastlane,aerticket-bookings,aerticket-voids,pipedrive-sync,activecampaign-sync,offer-recalculations,offer-generation,default --sleep=3 --tries=3 --max-time=3600Worker Behavior:
- Polls every 3 seconds for new jobs
- Max 3 retry attempts per job
- Max 1 hour runtime before graceful restart
- Auto-restart on container failure
Flight Search Worker (queue-flights)
Section titled “Flight Search Worker (queue-flights)”A second Compose service runs a dedicated worker for customer-facing flight cache work (SearchFlightCacheJob, CascadeFlightSearchForDateJob), isolated on the flight-searches queue:
queue-flights: command: php artisan queue:work --queue=flight-searches --sleep=3 --tries=3 --max-time=3600Isolating it keeps a burst of Aerticket searches from delaying ticketing/booking jobs, and keeps the main worker’s admin sweeps (offer-recalculations) from slowing customer-driven cache fills.
Source: backend/compose.yaml (queue and queue-flights services)
Production Worker Restarts (queue-worker.sh)
Section titled “Production Worker Restarts (queue-worker.sh)”In production the queue worker runs through the docker/common/queue-worker/queue-worker.sh wrapper, which passes --memory="$QUEUE_WORKER_MEMORY" (default 384 MB) to queue:work and validates that the value is a positive integer. Making the memory limit configurable via the QUEUE_WORKER_MEMORY env var fixed a memory-driven restart loop (#2220). The prod wrapper runs a single worker over the combined queue list (aerticket-tickets,aerticket-fastlane,aerticket-bookings,aerticket-voids,pipedrive-sync,activecampaign-sync,offer-recalculations,offer-generation,flight-searches,default).
Any job timeout also restarts the container. The wrapper execs queue:work under set -eu, so the worker is PID 1. Laravel’s job-timeout SIGALRM handler exits non-zero, PID 1 dies, and the container restarts — one restart per timed-out job, regardless of memory. This is a second, independent cause of the same restart-loop symptom as #2220, and it is why job $timeout values are treated as an operational constraint rather than a tuning knob (see Flight Search Queue).
Source: backend/docker/common/queue-worker/queue-worker.sh
Common Operations
Section titled “Common Operations”Restarting Workers
Section titled “Restarting Workers”# Signal workers to restart after current job./vendor/bin/sail artisan queue:restart
# Or restart the Docker container./vendor/bin/sail restart queueA worker boots the application once and keeps that code in memory for the life of the process, so a release that changes a queued class needs this signal or the worker keeps running the old code until it exits on its own.
The failure is asymmetric and easy to misread: anything sent with notifyNow()
goes out fine, because it renders in the web request on new code, while every
queued job fails. Transactional emails are exactly that shape — the customer’s own
email is immediate, but the copy recipients configured in System → Emails are
queued (TransactionalEmailCopier uses notify() so an operator’s click never
blocks on SMTP). A release without queue:restart therefore looks healthy —
customers receive their emails — while every internal copy lands in failed_jobs
with an error such as Call to undefined method for a method the release added.
Include queue:restart in the deploy steps for any release that touches a
Notification, Mailable, Job, or a model those call.
Handling Failed Jobs
Section titled “Handling Failed Jobs”# List all failed jobs./vendor/bin/sail artisan queue:failed
# Retry a specific failed job./vendor/bin/sail artisan queue:retry {job_id}
# Retry all failed jobs./vendor/bin/sail artisan queue:retry all
# Flush all failed jobs./vendor/bin/sail artisan queue:flushMonitoring Workers
Section titled “Monitoring Workers”# Check worker statusdocker ps | grep queue
# View worker logs./vendor/bin/sail logs queue
# Follow worker logs in real-time./vendor/bin/sail logs -f queueAerTicket Queue Jobs
Section titled “AerTicket Queue Jobs”Ticket Issuance Queue
Section titled “Ticket Issuance Queue”Queue Name: aerticket-tickets
Purpose: Process ticket issuance requests with highest priority
Jobs:
AerticketIssueTicketJob- Issues tickets for confirmed bookings
Configuration:
public int $tries = 3;public int $timeout = 120;public array $backoff = [30, 60, 180];Booking Creation Queue
Section titled “Booking Creation Queue”Queue Name: aerticket-bookings
Purpose: Process flight booking creation asynchronously
Jobs:
AerticketCreateBookingJob- Creates flight bookings from admin panelCreateCheckoutFlightBookingJob- Checkout flight booking job triggered from booking admin actionsAerticketRetrieveBookingJob- Retrieves PNR details
Checkout Flight Booking (Per-Leg):
After successful checkout payment, bookings with flights transition to pending_flight_booking. Admin users trigger CreateCheckoutFlightBookingJob from the booking detail page (Book All Flights / Retry per-leg). One job is dispatched per unbooked leg.
Configuration:
public int $tries = 3;public int $maxExceptions = 2;public int $timeout = 420;public array $backoff = [120];public int $uniqueFor = 480; // ShouldBeUnique per booking+legFeatures:
- Implements
ShouldBeUniquewithuniqueId = "checkout-flight:{bookingId}:{legIndex}" - Idempotency: a cache lock on
checkout-flight-booking:{bookingId}:{legIndex}held across the booking call, plus a per-legFlightBooking::where(booking_id, leg_index)->exists()check inside it. The check alone is not enough — its row lock is released when its transaction commits, before the supplier is called, so two concurrent runs would both find no booking and both book - International legs: re-searches round-trip, matches by flight numbers + price
- Domestic legs: searches one-way, matches by flight numbers + price
- Differentiated retry:
noMatchingFarereleases with 5-minute delay (fare refresh), other errors use 120s backoff - Notifies admins via Filament on per-leg success and all-legs-complete
- Creates
FlightBookingwithbooking_id,leg_index,flight_type - All-legs-booked check: only transitions to
flights_confirmedwhen ALL legs haveFlightBookingrecords
Source: backend/app/Jobs/CreateCheckoutFlightBookingJob.php
Related: Checkout API - Admin-Driven Flight Booking
Flight Search Queue
Section titled “Flight Search Queue”Queue Name: flight-searches
Purpose: Process dynamic flight cache searches asynchronously for bulk operations
Jobs:
SearchFlightCacheJob- Executes flight search for a single cache entry
Configuration:
public int $tries = 3;public int $timeout = 150;public array $backoff = [5, 15, 30];Why the timeout is 150s
Section titled “Why the timeout is 150s”The timeout is derived, not chosen: SIZED_FOR_MAX_SHIFTS (4) × AERTICKET_SEARCH_TIMEOUT (30s) + 30s persistence headroom.
Multi-city routes run DynamicFlightCachePopulatorService::executeMultiShiftSearchForEntry(), which issues one serial Aerticket call per observed overnight shift. The job’s wall clock is therefore shifts × search timeout, not a single search timeout. Most multi-city routes carry 3 or more shifts, so the previous ceiling — sized for two searches — timed out routinely, and every timeout restarted the worker container.
SearchFlightCacheJob::SIZED_FOR_MAX_SHIFTS is a production snapshot, not a hard limit: the shift set grows as routes learn new shapes. The populator logs a Multi-shift search exceeds the sized job timeout budget warning when a route exceeds it. That warning is the signal to re-derive $timeout — it arrives before the route starts timing out.
Do not change AERTICKET_SEARCH_TIMEOUT, $timeout, or $backoff in isolation. Three values are coupled, and FlightCacheTimeoutBudgetTest fails the build if the relationship breaks:
| Value | Constraint |
|---|---|
SearchFlightCacheJob::$timeout (150s) |
≥ SIZED_FOR_MAX_SHIFTS × AERTICKET_SEARCH_TIMEOUT plus persistence headroom |
DynamicFlightCache::SEARCHING_STALE_AFTER_MINUTES (10) |
> worst case timeout × tries + sum(backoff) = 500s ≈ 8.3 min |
Connection retry_after (240s) |
> $timeout, or the queue re-releases a still-running job and two workers write fares for the same cache entry |
AERTICKET_SEARCH_TIMEOUT defaults to 30s in config/aerticket.php (search.timeout); phpunit.xml pins it to the production value so the budget test asserts against production, not a developer’s local .env.
Features:
- User attribution for activity logging
- Automatic failure handling with status update
- Structured logging to Grafana Loki
- Activity log entries for audit trail
Usage:
use App\Jobs\SearchFlightCacheJob;
// Dispatch with user attributionSearchFlightCacheJob::dispatch($cacheId, $userId) ->onQueue('flight-searches');
// Via service (recommended)$service = app(DynamicFlightCachePopulatorService::class);$result = $service->dispatchSearchJobs($entries, $userId);Source: backend/app/Jobs/SearchFlightCacheJob.php
Related: Dynamic Flight Cache for full documentation
Offer Recalculation Queue
Section titled “Offer Recalculation Queue”Queue Name: offer-recalculations (RecalculateOfferFlightsJob::QUEUE_NAME)
Purpose: Run bulk offer flight/price recalculation sweeps off the main request path, isolated from customer-facing flight searches.
Jobs:
RecalculateOfferFlightsJob— recalculates an offer’s bound flights and prices
Consumed by the main queue worker (dev/local) and by the combined production worker.
RecalculateSupplierOfferPricesJob — the land re-pricing a signed supplier contract addendum triggers — is not on this queue: it runs on default, one attempt, 600 s timeout, and invokes offers:recalculate-prices in-process (Artisan::call()) for the whole supplier rather than per offer.
Source: backend/app/Jobs/RecalculateOfferFlightsJob.php, backend/app/Jobs/RecalculateSupplierOfferPricesJob.php
Offer Generation Queue
Section titled “Offer Generation Queue”Queue Name: offer-generation (GenerateAutoOffersJob::QUEUE_NAME)
Purpose: Run the auto-offer generator off the request path when an operator triggers it from the admin. A large product (five airports, a year of flight cache) needs minutes, far past the 60 s web ceiling — the inline action used to surface as a bare “Error while loading page” (nginx 504). The 15-minute scheduler does not use this queue: offers:auto-generate runs the generator directly inside the scheduler container.
Jobs:
GenerateAutoOffersJob(?int $productByMarketId = null, int $maxOffersPerDate = 1, ?int $userId = null)—nullproduct means all eligible products.tries=3,timeout=300,backoff=[30, 60, 120].SyncLastSeatsPricesJob(int $productByMarketId, string $departureDate, ?int $departureId = null)— re-persists one Last Seats campaign departure’s offer prices by callingoffers:recalculate-prices --apply --force --product --departure-date, then records the discount the offers now hold. It does not take theauto-offerslock.
Concurrency: every run takes the auto-offers cache lock (TTL = job timeout). A run that finds the lock held is released back to the queue with a 120 s delay rather than dropped, so a single-product dispatch arriving during a whole-catalogue run still gets its turn. Three lost attempts exhaust tries and the job fails with MaxAttemptsExceededException; the operator is told the run never started.
Reporting: when a userId is set (admin dispatch), the job sends the run summary — built by AutoOfferGenerationSummary — to that user’s notification bell as a Filament database notification with a “View offers” link; a notification failure is logged and swallowed (a deleted user is skipped silently) so a reporting problem never re-runs a generation whose offers are already written. See Offers → Triggers for the exact wording.
Consumed by the main queue worker (dev/local) and by the combined production worker. As with activecampaign-sync, the queue name is a constant on the job and the --queue= lists in compose.yaml and queue-worker.sh are hardcoded: a job dispatched to a queue no worker consumes sits in jobs forever, which is exactly how offers:auto-generate --queue behaved before offer-generation was added to those lists.
Source: backend/app/Jobs/GenerateAutoOffersJob.php, backend/app/Jobs/SyncLastSeatsPricesJob.php, backend/app/Services/Offers/AutoOfferGenerationSummary.php
Pipedrive Sync Queue
Section titled “Pipedrive Sync Queue”Queue Name: pipedrive-sync (configurable via PIPEDRIVE_QUEUE)
Purpose: Push CRM-relevant model changes to Pipedrive asynchronously via retryable jobs.
Dispatched by:
PipedriveSyncObserver, registered inapp/Providers/AppServiceProvider.phpforClient,Booking,Passenger,BookingUpsell,BookingNote, andLead.NewsletterSubscriptionController::store(), which dispatchesSyncNewsletterSubscriberToPipedriveJobexplicitly — that model has no observer, since the job writespipedrive_person_idback on success.SyncBookingToPipedriveJob, which dispatchesSyncProductByMarketToPipedriveJobandAttachProductsToDealJobonto the same queue.- The Artisan re-dispatchers
pipedrive:sync-allandpipedrive:retry-failed.
The worker must include this queue in its --queue= list, or these jobs will accumulate unprocessed in the jobs table. For newsletter signups that means the row persists locally and newsletter_subscribers.pipedrive_person_id stays null; the subscriber’s pipedrive_syncs row is the record of what happened.
Related: Leads API - Pipedrive Sync for the full sync flow, custom fields, and operational requirements. Newsletter Subscriptions API — Pipedrive Sync Behaviour for the newsletter Person mirror and its recovery commands.
ActiveCampaign Sync Queue
Section titled “ActiveCampaign Sync Queue”Queue Name: activecampaign-sync (SyncNewsletterSubscriberToActiveCampaignJob::QUEUE_NAME)
Purpose: Mirror newsletter subscribers into ActiveCampaign so marketing can run campaigns off them. List membership is what triggers marketing’s welcome automation; the market tag is what lets campaigns segment the single list.
Jobs:
SyncNewsletterSubscriberToActiveCampaignJob— initial delivery upserts one subscriber, adds it to the newsletter list, and tags its market; phone-only delivery fills an existing contact’s empty phone without changing subscription state
Dispatched by:
NewsletterSubscriptionController::store(), for newly created subscribers and, in phone-only mode, when a repeated signup fills the first local phone.activecampaign:sync-subscribers, the operator-invoked backfill/repair command — it dispatches one initial-delivery job per subscriber whoseactivecampaign_contact_idis stillnull(--limit=500,--dry-run), staggered one second apart. It excludes successfully mirrored rows, not necessarily every pre-existing remote contact; see the re-delivery caution. It does not repair phone-only failures on linked contacts. Not scheduled.
Initial-delivery API calls — 3 in steady state without a phone:
| Call | Payload |
|---|---|
POST /contact/sync |
contact wrapper: email, first/last name, and fieldValues for the three GDPR consent fields |
POST /contactLists |
configured list + contact id + status: 1 |
POST /contactTags |
contact id + the market tag’s id — skipped when the row has no market |
When a phone is supplied, initial delivery also reads /contacts/{id} and
updates only an empty phone. Phone-only delivery performs that guarded
read/update without any list, tag or consent writes; it searches by exact
email first if the local contact id is missing. It never creates a contact,
marks partial initial delivery complete, or reactivates an unsubscribed
contact. Existing CRM phones are preserved in both modes.
The consent fields and the market tag are addressed by numeric, account-specific ids, resolved from their name/title by ActiveCampaignSchemaResolver (GET /fields, GET /tags, creating the object when missing). Those lookups are cached for 7 days in the default cache store, so they amortize to roughly one per field/tag per week rather than adding calls per subscriber.
Configuration:
public int $tries = 3;public int $timeout = 120;public array $backoff = [30, 60, 180];Unlike pipedrive-sync, the queue name is not env-configurable — it is a constant on the job. The worker --queue= lists in compose.yaml and queue-worker.sh are hardcoded, so an env override would silently route jobs to a queue no worker consumes.
Consumed by the main queue worker (dev/local) and by the combined production worker. If the queue is missing from the --queue= list, subscribers still persist locally but never reach ActiveCampaign — newsletter_subscribers.activecampaign_contact_id stays null, which is the marker for an unmirrored row and exactly what activecampaign:sync-subscribers re-dispatches once the queue is fixed.
In environments without ActiveCampaign credentials (local, CI) the job checks ActiveCampaignClient::isConfigured(), logs at info, and returns, so it never accumulates failed jobs. The backfill command applies the same check before dispatching anything: with credentials missing and rows pending it errors and exits 1 instead of queueing jobs that would each skip.
Source: backend/app/Jobs/ActiveCampaign/SyncNewsletterSubscriberToActiveCampaignJob.php, backend/app/Services/ActiveCampaign/ActiveCampaignSchemaResolver.php, backend/app/Console/Commands/ActiveCampaignSyncSubscribersCommand.php
Related: Newsletter Subscriptions API — ActiveCampaign Sync Behaviour for endpoint contract, retry semantics, and configuration.
Security Best Practices
Section titled “Security Best Practices”Validate Job Data
Section titled “Validate Job Data”public function __construct( public int $userId, public string $action) { if (!User::find($userId)) { throw new \InvalidArgumentException('Invalid user ID'); }}Encrypt Sensitive Data
Section titled “Encrypt Sensitive Data”use Illuminate\Contracts\Queue\ShouldBeEncrypted;
class ProcessPayment implements ShouldQueue, ShouldBeEncrypted{ public function __construct( public string $creditCardNumber ) {}}Rate Limiting
Section titled “Rate Limiting”use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array{ return [new RateLimited('api-calls')];}Troubleshooting
Section titled “Troubleshooting”Worker Not Processing Jobs
Section titled “Worker Not Processing Jobs”- Check worker is running:
docker ps | grep queue- Check worker logs:
./vendor/bin/sail logs queue- Restart worker:
./vendor/bin/sail restart queueJobs Failing Repeatedly
Section titled “Jobs Failing Repeatedly”- Check failed job details:
./vendor/bin/sail artisan queue:failed- Review exception:
./vendor/bin/sail artisan tinker>>> DB::table('failed_jobs')->latest()->first()->exception;- Fix issue and retry:
./vendor/bin/sail artisan queue:retry allConnection Timeouts
Section titled “Connection Timeouts”A job’s $timeout must stay below the connection’s retry_after, or the queue
re-releases the still-running job to a second worker and it executes twice. Raise
$timeout and retry_after together, and check the job’s own docblock first —
several timeouts (notably SearchFlightCacheJob) are derived from an upstream
API budget rather than picked, so raising them without re-deriving hides a real
problem.
retry_after defaults to 240s on the database, beanstalkd and redis
connections (DB_QUEUE_RETRY_AFTER, BEANSTALKD_QUEUE_RETRY_AFTER,
REDIS_QUEUE_RETRY_AFTER). Production runs the database driver on PostgreSQL.
The sqs driver exposes no retry_after — its AWS-side visibility timeout is
the equivalent — but that connection is an unwired placeholder here.