diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac9fbbe..47c7329 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,6 +15,55 @@ jobs: name: PHP 8.5 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: queue_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -ppassword" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: queue_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + # A backend that is down must fail the run, not silently skip its tests. + QUEUE_TEST_REQUIRE_BACKENDS: 1 + QUEUE_TEST_REDIS_URL: redis://127.0.0.1:6379 + QUEUE_TEST_MYSQL_DSN: mysql:host=127.0.0.1;port=3306;dbname=queue_test;charset=utf8mb4 + QUEUE_TEST_MYSQL_USER: root + QUEUE_TEST_MYSQL_PASS: password + QUEUE_TEST_PGSQL_DSN: pgsql:host=127.0.0.1;port=5432;dbname=queue_test + QUEUE_TEST_PGSQL_USER: postgres + QUEUE_TEST_PGSQL_PASS: password + QUEUE_TEST_PGSQL_SCHEMA: queue_test + steps: - name: Checkout code uses: actions/checkout@v4 @@ -23,7 +72,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.5' - extensions: dom, curl, libxml, mbstring, zip + extensions: dom, curl, libxml, mbstring, zip, pdo_sqlite, pdo_mysql, pdo_pgsql, pcntl, posix ini-values: error_reporting=E_ALL tools: composer:v2 coverage: none diff --git a/composer.json b/composer.json index bf03643..b0ed1a9 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "doppar/framework": "4.*", "mockery/mockery": "^1.6", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^13.3" + "phpunit/phpunit": "^13.3", + "predis/predis": "^3.3" }, "autoload": { "psr-4": { @@ -37,6 +38,9 @@ "php": "^8.5", "opis/closure": "^4.5" }, + "suggest": { + "predis/predis": "Required to use the \"redis\" queue driver." + }, "prefer-stable": true, "scripts": { "analyse": "vendor/bin/phpstan analyse --memory-limit=1G" diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..a0c77a3 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,67 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Supported drivers: "database", "redis", "memory". + | + | "lease" is how many seconds a worker may hold a job before it is handed + | to another worker, which is how jobs of crashed workers are recovered. + | Set it above the longest time a job needs; jobs with a timeout renew it + | automatically while they run. + | + | Register your own driver with Queue::extend('name', fn ($config, $clock) => ...). + | + */ + + 'connections' => [ + + 'database' => [ + 'driver' => 'database', + // Database connection to use; null uses the default connection. + 'connection' => null, + 'table' => 'queue_jobs', + 'failed_table' => 'failed_jobs', + 'lease' => 90, + ], + + 'redis' => [ + 'driver' => 'redis', + // Requires predis/predis. Same shape as the "redis" cache store. + 'connection' => env('REDIS_URL', 'redis://127.0.0.1:6379'), + 'options' => [ + 'parameters' => [ + 'password' => env('REDIS_PASSWORD', null), + 'database' => env('REDIS_DB', 0), + ], + ], + // The braces are a Redis Cluster hash tag; keep them. + 'prefix' => '{doppar_queue}', + 'lease' => 90, + ], + + // Keeps jobs in memory for the current process only. For tests. + 'memory' => [ + 'driver' => 'memory', + 'lease' => 90, + ], + + ], + +]; diff --git a/src/Attributes/Queueable.php b/src/Attributes/Queueable.php index 505a6a3..c95cb5d 100644 --- a/src/Attributes/Queueable.php +++ b/src/Attributes/Queueable.php @@ -13,12 +13,18 @@ class Queueable * @param int|null $delayFor * @param string|null $onQueue * @param int|null $timeout + * @param int|null $priority + * @param string|null $onConnection + * @param int|array|null $backoff */ public function __construct( public ?int $tries = null, public ?int $retryAfter = null, public ?int $delayFor = null, public ?string $onQueue = null, - public ?int $timeout = null + public ?int $timeout = null, + public ?int $priority = null, + public ?string $onConnection = null, + public int|array|null $backoff = null ) {} } diff --git a/src/Commands/Concerns/ReadsOptions.php b/src/Commands/Concerns/ReadsOptions.php new file mode 100644 index 0000000..a41c34f --- /dev/null +++ b/src/Commands/Concerns/ReadsOptions.php @@ -0,0 +1,19 @@ +option($key); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Commands/QueueFailedCommand.php b/src/Commands/QueueFailedCommand.php index 68d65f0..e9085e5 100644 --- a/src/Commands/QueueFailedCommand.php +++ b/src/Commands/QueueFailedCommand.php @@ -3,35 +3,37 @@ namespace Doppar\Queue\Commands; use Phaseolies\Console\Schedule\Command; -use Doppar\Queue\Models\FailedJob; +use Doppar\Queue\Commands\Concerns\ReadsOptions; +use Doppar\Queue\QueueManager; class QueueFailedCommand extends Command { + use ReadsOptions; + /** - * The name of the console command. + * The name and signature of the console command. * * @var string */ - protected $name = 'queue:failed'; + protected $name = 'queue:failed {--connection=}'; /** - * The command description. + * The console command description. * * @var string */ protected $description = 'List all failed jobs'; /** - * Execute the console command - * Example: php pool queue:failed + * Execute the console command. * * @return int */ public function handle(): int { - $failedJobs = FailedJob::orderBy('failed_at', 'desc')->get(); + $failedJobs = app(QueueManager::class)->connection($this->stringOption('connection'))->failedJobs(); - if ($failedJobs->isEmpty()) { + if ($failedJobs === []) { $this->info("No failed jobs found."); return Command::SUCCESS; } @@ -41,20 +43,18 @@ public function handle(): int $table->setHeaders(['ID', 'Job', 'Queue', 'Failed At']); foreach ($failedJobs as $job) { - $payload = $job->payload; - $data = unserialize($payload); + $data = @unserialize($job->payload); $jobClass = null; - if ($data && isset($data['job']) && is_object($data['job'])) { + if (is_array($data) && isset($data['job']) && is_object($data['job'])) { $jobClass = get_class($data['job']); } - $failedAt = date('Y-m-d H:i:s', $job->failed_at); $table->addRow([ $job->id, $jobClass, $job->queue, - $failedAt + date('Y-m-d H:i:s', $job->failedAt), ]); } diff --git a/src/Commands/QueueFlushCommand.php b/src/Commands/QueueFlushCommand.php index b5b5736..153212d 100644 --- a/src/Commands/QueueFlushCommand.php +++ b/src/Commands/QueueFlushCommand.php @@ -3,58 +3,50 @@ namespace Doppar\Queue\Commands; use Phaseolies\Console\Schedule\Command; -use Doppar\Queue\Models\FailedJob; +use Doppar\Queue\Commands\Concerns\ReadsOptions; +use Doppar\Queue\QueueManager; class QueueFlushCommand extends Command { + use ReadsOptions; + /** - * The name of the console command. + * The name and signature of the console command. * * @var string */ - protected $name = 'queue:flush {--id=}'; + protected $name = 'queue:flush {--id=} {--connection=}'; /** - * The command description. + * The console command description. * * @var string */ protected $description = 'Delete failed job(s) by ID or all if no ID is provided'; /** - * Execute the console command - * Example: php pool queue:flush --id=1 + * Execute the console command. * * @return int */ public function handle(): int { - $id = $this->option('id'); + $driver = app(QueueManager::class)->connection($this->stringOption('connection')); + $id = $this->stringOption('id'); if ($id) { - return $this->flushJobById($id); - } + if (!$driver->forgetFailed($id)) { + $this->error("Failed job with ID {$id} not found."); + return Command::FAILURE; + } - FailedJob::query() - ->cursor(function (FailedJob $failedJob) { - $failedJob->delete(); - $this->info("✔ Job with ID {$failedJob->id} has been deleted."); - }); - - return Command::SUCCESS; - } - - protected function flushJobById(int $id): int - { - $failedJob = FailedJob::find($id); + $this->info("✔ Job with ID {$id} has been deleted."); - if (!$failedJob) { - $this->error("Failed job with ID {$id} not found."); - return Command::FAILURE; + return Command::SUCCESS; } - $failedJob->delete(); - $this->info("✔ Job with ID {$failedJob->id} has been deleted."); + $count = $driver->flushFailed(); + $this->info("✔ {$count} failed job(s) deleted."); return Command::SUCCESS; } diff --git a/src/Commands/QueueMonitorCommand.php b/src/Commands/QueueMonitorCommand.php index e6064aa..9450295 100644 --- a/src/Commands/QueueMonitorCommand.php +++ b/src/Commands/QueueMonitorCommand.php @@ -3,20 +3,22 @@ namespace Doppar\Queue\Commands; use Phaseolies\Console\Schedule\Command; -use Doppar\Queue\Models\QueueJob; -use Doppar\Queue\Models\FailedJob; +use Doppar\Queue\Commands\Concerns\ReadsOptions; +use Doppar\Queue\QueueManager; class QueueMonitorCommand extends Command { + use ReadsOptions; + /** * The name and signature of the console command. * * @var string */ - protected $name = 'queue:monitor'; + protected $name = 'queue:monitor {--connection=}'; /** - * The description of the console command. + * The console command description. * * @var string */ @@ -29,39 +31,34 @@ class QueueMonitorCommand extends Command */ public function handle(): int { - $queues = QueueJob::groupBy('queue')->pluck('queue'); + $manager = app(QueueManager::class); + $connection = $this->stringOption('connection'); + $driver = $manager->connection($connection); // Create table for queue statistics $table = $this->createTable(); - $table->setHeaders(['Queue', 'Pending', 'Processing']); - - foreach ($queues ?? [] as $queue) { - $pending = QueueJob::where('queue', $queue) - ->whereNull('reserved_at') - ->count(); + $table->setHeaders(['Queue', 'Ready', 'Delayed', 'Processing']); - $processing = QueueJob::where('queue', $queue) - ->whereNotNull('reserved_at') - ->count(); + foreach ($driver->queues() as $queue) { + $stats = $driver->stats($queue); $table->addRow([ $queue, - $pending, - $processing, + $stats['ready'], + $stats['delayed'], + $stats['reserved'], ]); } // Render queue table $this->newLine(); - $this->info("Queue Statistics"); + $this->info("Queue Statistics (connection: " . ($connection ?? $manager->getDefaultConnection()) . ")"); $table->render(); // Failed jobs table - $failedCount = FailedJob::count(); - $failedTable = $this->createTable(); $failedTable->setHeaders(['Metric', 'Value']); - $failedTable->addRow(['Failed Jobs', $failedCount]); + $failedTable->addRow(['Failed Jobs', $driver->countFailed()]); $this->info("\nFailed Jobs Summary"); $failedTable->render(); diff --git a/src/Commands/QueueRetryCommand.php b/src/Commands/QueueRetryCommand.php index 3c18e45..71fcd05 100644 --- a/src/Commands/QueueRetryCommand.php +++ b/src/Commands/QueueRetryCommand.php @@ -3,83 +3,80 @@ namespace Doppar\Queue\Commands; use Phaseolies\Console\Schedule\Command; +use Doppar\Queue\Commands\Concerns\ReadsOptions; use Doppar\Queue\QueueManager; -use Doppar\Queue\Models\FailedJob; +use Doppar\Queue\Support\FailedJobRecord; class QueueRetryCommand extends Command { + use ReadsOptions; + /** - * The name of the console command. + * The name and signature of the console command. * * @var string */ - protected $name = 'queue:retry {--id=}'; + protected $name = 'queue:retry {--id=} {--connection=}'; /** - * The command description. + * The console command description. * * @var string */ protected $description = 'Retry failed job(s) by ID or all if no ID is provided'; /** - * Execute the console command - * Example: php pool queue:retry --id=4 + * Execute the console command. * * @return int */ public function handle(): int { - $id = $this->option('id'); $manager = app(QueueManager::class); + $connection = $this->stringOption('connection'); + $id = $this->stringOption('id'); if ($id) { - return $this->retryJobById($manager, $id); - } + $record = $manager->connection($connection)->findFailed($id); - FailedJob::query() - ->cursor(function (FailedJob $failedJob) use ($manager) { - $this->retryFailedJob($manager, $failedJob); - }); - - return Command::SUCCESS; - } - - protected function retryJobById(QueueManager $manager, int $id): int - { - $failedJob = FailedJob::find($id); + if ($record === null) { + $this->error("Failed job with ID {$id} not found."); + return Command::FAILURE; + } - if (!$failedJob) { - $this->error("Failed job with ID {$id} not found."); - return Command::FAILURE; + return $this->retryFailedJob($manager, $record, $connection) ? Command::SUCCESS : Command::FAILURE; } - if ($this->retryFailedJob($manager, $failedJob)) { - return Command::SUCCESS; + foreach ($manager->connection($connection)->failedJobs() as $record) { + $this->retryFailedJob($manager, $record, $connection); } - return Command::FAILURE; + return Command::SUCCESS; } - protected function retryFailedJob(QueueManager $manager, FailedJob $failedJob): bool + /** + * Push one failed job back onto its queue. + * + * @param QueueManager $manager + * @param FailedJobRecord $record + * @param string|null $connection + * @return bool + */ + protected function retryFailedJob(QueueManager $manager, FailedJobRecord $record, ?string $connection): bool { try { - $job = $manager->unserializeJob($failedJob->payload); + $job = $manager->unserializeJob($record->payload); $jobClass = get_class($job); - // Reset attempts - $job->attempts = 0; - - // Push back to queue - $manager->push($job); - - // Delete from failed jobs - $failedJob->delete(); + if (!$manager->retryFailed($record, $connection)) { + $this->error("✖ Job ID {$record->id} was not requeued (a unique job with the same key is already queued)."); + return false; + } - $this->info("✔ Retried job [{$jobClass}] (ID: {$failedJob->id})"); + $this->info("✔ Retried job [{$jobClass}] (ID: {$record->id})"); return true; } catch (\Throwable $e) { - $this->error("✖ Failed to retry job ID {$failedJob->id}: " . $e->getMessage()); + $this->error("✖ Failed to retry job ID {$record->id}: " . $e->getMessage()); return false; } } diff --git a/src/Commands/QueueRunCommand.php b/src/Commands/QueueRunCommand.php index 4d4dbff..a7a5d6d 100644 --- a/src/Commands/QueueRunCommand.php +++ b/src/Commands/QueueRunCommand.php @@ -3,17 +3,20 @@ namespace Doppar\Queue\Commands; use Phaseolies\Console\Schedule\Command; +use Doppar\Queue\Commands\Concerns\ReadsOptions; use Doppar\Queue\QueueWorker; use Doppar\Queue\QueueManager; class QueueRunCommand extends Command { + use ReadsOptions; + /** * The name of the console command. * * @var string */ - protected $name = 'queue:run {--queue=default} {--sleep=3} {--memory=128} {--timeout=0} {--limit=}'; + protected $name = 'queue:run {--queue=default} {--connection=} {--sleep=3} {--memory=128} {--timeout=0} {--limit=}'; /** * The command description. @@ -57,7 +60,8 @@ public function __construct(QueueManager $manager) public function handle(): int { return $this->withTiming(function () { - $queue = $this->option('queue', 'default'); + $queue = $this->stringOption('queue') ?? 'default'; + $connection = $this->stringOption('connection'); $sleep = (int) $this->option('sleep', 3); $maxMemory = (int) $this->option('memory', 128); @@ -67,7 +71,9 @@ public function handle(): int $maxLimit = $this->option('limit'); $maxLimit = $maxLimit !== null ? (int) $maxLimit : null; - $this->displaySuccess("Starting queue worker on queue: {$queue}"); + $this->displaySuccess( + "Starting queue worker on queue: {$queue} (connection: " . ($connection ?? $this->manager->getDefaultConnection()) . ')' + ); $configInfo = "Configuration: sleep={$sleep}s, memory={$maxMemory}MB, timeout="; if ($maxTime > 0) { @@ -105,6 +111,7 @@ public function handle(): int 'maxMemory' => $maxMemory, 'maxExecutionTime' => $maxTime, 'maxJobs' => $maxLimit, + 'connection' => $connection, ]); return Command::SUCCESS; diff --git a/src/Contracts/QueueDriver.php b/src/Contracts/QueueDriver.php new file mode 100644 index 0000000..12de122 --- /dev/null +++ b/src/Contracts/QueueDriver.php @@ -0,0 +1,149 @@ + $envelopes + * @return int + */ + public function pushMany(array $envelopes): int; + + /** + * Claim the next available job, trying the queues in the order given. + * + * @param string|array $queues + * @param int|null $leaseFor + * @return ReservedJob|null + */ + public function pop(string|array $queues, ?int $leaseFor = null): ?ReservedJob; + + /** + * Remove a finished job. + * + * @param ReservedJob $job + * @return bool + */ + public function delete(ReservedJob $job): bool; + + /** + * Give a claimed job back, to run again after the delay. + * + * @param ReservedJob $job + * @param int $delay Seconds + * @return bool + */ + public function release(ReservedJob $job, int $delay = 0): bool; + + /** + * Push the lease of a claimed job out to the given number of seconds from now. + * + * @param ReservedJob $job + * @param int $seconds + * @return bool + */ + public function extend(ReservedJob $job, int $seconds): bool; + + /** + * Move a claimed job to the failed store. + * + * @param ReservedJob $job + * @param string $exception Formatted exception text + * @return bool + */ + public function fail(ReservedJob $job, string $exception): bool; + + /** + * Jobs on a queue that are not currently reserved (ready plus delayed). + * + * @param string $queue + * @return int + */ + public function size(string $queue): int; + + /** + * Break a queue down by state. + * + * @param string $queue + * @return array{ready: int, delayed: int, reserved: int} + */ + public function stats(string $queue): array; + + /** + * Names of the queues that currently hold jobs. + * + * @return array + */ + public function queues(): array; + + /** + * Delete every job on a queue. + * + * @param string $queue + * @return int + */ + public function clear(string $queue): int; + + /** + * All failed jobs, newest first. + * + * @return array + */ + public function failedJobs(): array; + + /** + * @param string|int $id + * @return FailedJobRecord|null + */ + public function findFailed(string|int $id): ?FailedJobRecord; + + /** + * @param string|int $id + * @return bool + */ + public function forgetFailed(string|int $id): bool; + + /** + * Delete every failed job. + * + * @return int + */ + public function flushFailed(): int; + + /** + * @return int + */ + public function countFailed(): int; +} diff --git a/src/Dispatchable.php b/src/Dispatchable.php index 283dd0b..7cb4041 100644 --- a/src/Dispatchable.php +++ b/src/Dispatchable.php @@ -31,9 +31,9 @@ public static function queueAsSync(...$args): void * * @param int $delay * @param mixed ...$args - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public static function queueAfter(int $delay, ...$args): string + public static function queueAfter(int $delay, ...$args): ?string { return (new static(...$args))->delayFor($delay)->forceQueue(); } @@ -43,9 +43,9 @@ public static function queueAfter(int $delay, ...$args): string * * @param string $queue * @param mixed ...$args - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public static function queueOn(string $queue, ...$args): string + public static function queueOn(string $queue, ...$args): ?string { return (new static(...$args))->onQueue($queue)->forceQueue(); } diff --git a/src/Drivers/BaseDriver.php b/src/Drivers/BaseDriver.php new file mode 100644 index 0000000..787554d --- /dev/null +++ b/src/Drivers/BaseDriver.php @@ -0,0 +1,80 @@ + + */ + protected array $config; + + /** + * @var Closure(): int + */ + protected Closure $clock; + + /** + * @param array $config + * @param (Closure(): int)|null $clock + */ + public function __construct(array $config = [], ?Closure $clock = null) + { + $this->config = $config; + $this->clock = $clock ?? static fn(): int => time(); + } + + /** + * Get the current unix time + * + * @return int + */ + protected function now(): int + { + return ($this->clock)(); + } + + /** + * Get the lease length to use for a claim + * + * @param int|null $leaseFor + * @return int + */ + protected function leaseSeconds(?int $leaseFor): int + { + return max(1, $leaseFor ?? (int) ($this->config['lease'] ?? self::DEFAULT_LEASE)); + } + + /** + * Normalize a queue name or list into a list of names + * + * @param string|array $queues + * @return array + */ + protected function queueList(string|array $queues): array + { + if (is_string($queues)) { + $queues = explode(',', $queues); + } + + $names = []; + + foreach ($queues as $queue) { + $queue = trim((string) $queue); + + if ($queue !== '' && !in_array($queue, $names, true)) { + $names[] = $queue; + } + } + + return $names; + } +} diff --git a/src/Drivers/DatabaseDriver.php b/src/Drivers/DatabaseDriver.php new file mode 100644 index 0000000..f853d77 --- /dev/null +++ b/src/Drivers/DatabaseDriver.php @@ -0,0 +1,538 @@ +pdo()->prepare( + "INSERT INTO {$this->table()} " + . '(queue, payload, attempts, priority, reserved_at, lease_expires_at, available_at, unique_key, created_at) ' + . 'VALUES (?, ?, 0, ?, NULL, NULL, ?, ?, ?)' + ); + + $this->run($statement, $this->row($envelope)); + + return true; + } catch (PDOException $e) { + if ($envelope->uniqueKey !== null && $this->isConstraintViolation($e)) { + return false; + } + + throw $e; + } + } + + /** + * @inheritDoc + */ + public function pushMany(array $envelopes): int + { + $stored = 0; + $chunk = []; + + foreach ($envelopes as $envelope) { + // A unique job goes through push() so a duplicate is skipped on + // its own instead of failing the whole multi-row insert. + if ($envelope->uniqueKey !== null) { + $stored += $this->insertChunk($chunk); + $chunk = []; + $stored += $this->push($envelope) ? 1 : 0; + continue; + } + + $chunk[] = $envelope; + + if (count($chunk) >= self::INSERT_CHUNK) { + $stored += $this->insertChunk($chunk); + $chunk = []; + } + } + + return $stored + $this->insertChunk($chunk); + } + + /** + * @inheritDoc + */ + public function pop(string|array $queues, ?int $leaseFor = null): ?ReservedJob + { + $now = $this->now(); + $lease = $this->leaseSeconds($leaseFor); + $default = (int) ($this->config['lease'] ?? self::DEFAULT_LEASE); + $pdo = $this->pdo(); + + // A job is claimable when unreserved, or when its lease ran out. Rows + // written before leases existed have no lease_expires_at, so they + // expire a default lease after they were reserved. + $claimable = '(reserved_at IS NULL OR reserved_at = 0 OR COALESCE(lease_expires_at, reserved_at + ?) <= ?)'; + + $select = $pdo->prepare( + "SELECT id, payload, attempts FROM {$this->table()} " + . "WHERE queue = ? AND available_at <= ? AND {$claimable} " + . 'ORDER BY priority DESC, id ASC LIMIT 1' + ); + + $claim = $pdo->prepare( + "UPDATE {$this->table()} SET reserved_at = ?, lease_expires_at = ?, attempts = attempts + 1 " + . "WHERE id = ? AND attempts = ? AND {$claimable}" + ); + + foreach ($this->queueList($queues) as $queue) { + for ($try = 0; $try < self::CLAIM_ATTEMPTS; $try++) { + $this->run($select, [$queue, $now, $default, $now]); + $row = $select->fetch(PDO::FETCH_ASSOC); + $select->closeCursor(); + + if ($row === false) { + break; + } + + $this->run($claim, [$now, $now + $lease, (int) $row['id'], (int) $row['attempts'], $default, $now]); + + if ($claim->rowCount() === 1) { + return new ReservedJob( + (int) $row['id'], + $queue, + $this->decodePayload($row['payload']), + (int) $row['attempts'] + 1, + $now, + $now + $lease + ); + } + + // Another worker claimed this row first; try the next candidate. + } + } + + return null; + } + + /** + * @inheritDoc + */ + public function delete(ReservedJob $job): bool + { + $statement = $this->pdo()->prepare( + "DELETE FROM {$this->table()} WHERE id = ? AND attempts = ? AND reserved_at IS NOT NULL" + ); + $this->run($statement, [$job->id, $job->attempts]); + + return $statement->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function release(ReservedJob $job, int $delay = 0): bool + { + $statement = $this->pdo()->prepare( + "UPDATE {$this->table()} SET reserved_at = NULL, lease_expires_at = NULL, available_at = ? " + . 'WHERE id = ? AND attempts = ? AND reserved_at IS NOT NULL' + ); + $this->run($statement, [$this->now() + max(0, $delay), $job->id, $job->attempts]); + + return $statement->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function extend(ReservedJob $job, int $seconds): bool + { + $statement = $this->pdo()->prepare( + "UPDATE {$this->table()} SET lease_expires_at = ? " + . 'WHERE id = ? AND attempts = ? AND reserved_at IS NOT NULL' + ); + $this->run($statement, [$this->now() + $seconds, $job->id, $job->attempts]); + + return $statement->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function fail(ReservedJob $job, string $exception): bool + { + $pdo = $this->pdo(); + + // Join a transaction the caller already opened; otherwise own one. + $opened = !$pdo->inTransaction(); + + if ($opened) { + $pdo->beginTransaction(); + } + + try { + $select = $pdo->prepare( + "SELECT queue, payload FROM {$this->table()} WHERE id = ? AND attempts = ? AND reserved_at IS NOT NULL" + ); + $this->run($select, [$job->id, $job->attempts]); + $row = $select->fetch(PDO::FETCH_ASSOC); + $select->closeCursor(); + + $delete = $pdo->prepare( + "DELETE FROM {$this->table()} WHERE id = ? AND attempts = ? AND reserved_at IS NOT NULL" + ); + $this->run($delete, [$job->id, $job->attempts]); + + if ($row === false || $delete->rowCount() !== 1) { + if ($opened) { + $opened = false; + $pdo->rollBack(); + } + + return false; + } + + $this->run($pdo->prepare( + "INSERT INTO {$this->failedTable()} (connection, queue, payload, exception, failed_at) VALUES (?, ?, ?, ?, ?)" + ), [ + $this->config['name'] ?? 'database', + $row['queue'], + $row['payload'], + $this->storableText($exception), + $this->now(), + ]); + + if ($opened) { + $opened = false; + $pdo->commit(); + } + + return true; + } catch (\Throwable $e) { + if ($opened) { + $pdo->rollBack(); + } + + throw $e; + } + } + + /** + * @inheritDoc + */ + public function size(string $queue): int + { + $stats = $this->stats($queue); + + return $stats['ready'] + $stats['delayed']; + } + + /** + * @inheritDoc + */ + public function stats(string $queue): array + { + $now = $this->now(); + $default = (int) ($this->config['lease'] ?? self::DEFAULT_LEASE); + + $statement = $this->pdo()->prepare( + "SELECT + SUM(CASE WHEN (reserved_at IS NULL OR reserved_at = 0 OR COALESCE(lease_expires_at, reserved_at + ?) <= ?) AND available_at <= ? THEN 1 ELSE 0 END) AS ready_count, + SUM(CASE WHEN (reserved_at IS NULL OR reserved_at = 0 OR COALESCE(lease_expires_at, reserved_at + ?) <= ?) AND available_at > ? THEN 1 ELSE 0 END) AS delayed_count, + SUM(CASE WHEN NOT (reserved_at IS NULL OR reserved_at = 0 OR COALESCE(lease_expires_at, reserved_at + ?) <= ?) THEN 1 ELSE 0 END) AS reserved_count + FROM {$this->table()} WHERE queue = ?" + ); + $this->run($statement, [$default, $now, $now, $default, $now, $now, $default, $now, $queue]); + $row = $statement->fetch(PDO::FETCH_ASSOC) ?: []; + + return [ + 'ready' => (int) ($row['ready_count'] ?? 0), + 'delayed' => (int) ($row['delayed_count'] ?? 0), + 'reserved' => (int) ($row['reserved_count'] ?? 0), + ]; + } + + /** + * @inheritDoc + */ + public function queues(): array + { + $names = $this->query("SELECT DISTINCT queue FROM {$this->table()} ORDER BY queue") + ->fetchAll(PDO::FETCH_COLUMN); + + return array_map('strval', $names); + } + + /** + * @inheritDoc + */ + public function clear(string $queue): int + { + $statement = $this->pdo()->prepare("DELETE FROM {$this->table()} WHERE queue = ?"); + $this->run($statement, [$queue]); + + return $statement->rowCount(); + } + + /** + * @inheritDoc + */ + public function failedJobs(): array + { + $rows = $this->query( + "SELECT id, connection, queue, payload, exception, failed_at FROM {$this->failedTable()} ORDER BY failed_at DESC, id DESC" + )->fetchAll(PDO::FETCH_ASSOC); + + return array_map($this->failedRecord(...), $rows); + } + + /** + * @inheritDoc + */ + public function findFailed(string|int $id): ?FailedJobRecord + { + $statement = $this->pdo()->prepare( + "SELECT id, connection, queue, payload, exception, failed_at FROM {$this->failedTable()} WHERE id = ?" + ); + $this->run($statement, [(int) $id]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + + return $row === false ? null : $this->failedRecord($row); + } + + /** + * @inheritDoc + */ + public function forgetFailed(string|int $id): bool + { + $statement = $this->pdo()->prepare("DELETE FROM {$this->failedTable()} WHERE id = ?"); + $this->run($statement, [(int) $id]); + + return $statement->rowCount() === 1; + } + + /** + * @inheritDoc + */ + public function flushFailed(): int + { + return (int) $this->pdo()->exec("DELETE FROM {$this->failedTable()}"); + } + + /** + * @inheritDoc + */ + public function countFailed(): int + { + return (int) $this->query("SELECT COUNT(*) FROM {$this->failedTable()}")->fetchColumn(); + } + + /** + * Run a query that has no parameters. + * + * @param string $sql + * @return \PDOStatement + * @throws PDOException + */ + private function query(string $sql): \PDOStatement + { + $statement = $this->pdo()->query($sql); + + if ($statement === false) { + throw new PDOException("Queue query failed: {$sql}"); + } + + return $statement; + } + + /** + * Execute a prepared statement with typed bindings. + * + * Integers must be bound as integers: PDO binds everything as a string by + * default, and SQLite orders any integer before any string, so a comparison + * such as `COALESCE(lease_expires_at, ...) <= ?` would silently misbehave. + * + * @param \PDOStatement $statement + * @param array $params + * @return \PDOStatement + */ + private function run(\PDOStatement $statement, array $params): \PDOStatement + { + foreach (array_values($params) as $index => $value) { + $statement->bindValue($index + 1, $value, match (true) { + is_int($value) => PDO::PARAM_INT, + $value === null => PDO::PARAM_NULL, + default => PDO::PARAM_STR, + }); + } + + $statement->execute(); + + return $statement; + } + + /** + * Insert several non-unique envelopes with one statement + * + * @param array $envelopes + * @return int + */ + private function insertChunk(array $envelopes): int + { + if ($envelopes === []) { + return 0; + } + + $placeholders = implode(', ', array_fill(0, count($envelopes), '(?, ?, 0, ?, NULL, NULL, ?, ?, ?)')); + + $statement = $this->pdo()->prepare( + "INSERT INTO {$this->table()} " + . '(queue, payload, attempts, priority, reserved_at, lease_expires_at, available_at, unique_key, created_at) ' + . "VALUES {$placeholders}" + ); + + $this->run($statement, array_merge(...array_map($this->row(...), $envelopes))); + + return count($envelopes); + } + + /** + * Bind values for one row of the insert column list + * + * @param Envelope $envelope + * @return array + */ + private function row(Envelope $envelope): array + { + return [ + $envelope->queue, + $this->encodePayload($envelope->payload), + $envelope->priority, + $envelope->availableAt, + $envelope->uniqueKey, + $envelope->createdAt ?: $this->now(), + ]; + } + + /** + * @param array $row + * @return FailedJobRecord + */ + private function failedRecord(array $row): FailedJobRecord + { + return new FailedJobRecord( + (int) $row['id'], + (string) $row['connection'], + (string) $row['queue'], + $this->decodePayload((string) $row['payload']), + (string) $row['exception'], + (int) $row['failed_at'] + ); + } + + /** + * Prepare a payload for a text column. + * + * @param string $payload + * @return string + */ + private function encodePayload(string $payload): string + { + $needsEncoding = str_starts_with($payload, self::ENCODED_PREFIX) + || !mb_check_encoding($payload, 'UTF-8') + || (str_contains($payload, "\0") && $this->pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql'); + + return $needsEncoding ? self::ENCODED_PREFIX . base64_encode($payload) : $payload; + } + + /** + * Make diagnostic text safe for a text column + * + * @param string $text + * @return string + */ + private function storableText(string $text): string + { + $text = str_replace("\0", '\\0', $text); + + return mb_check_encoding($text, 'UTF-8') ? $text : mb_scrub($text, 'UTF-8'); + } + + /** + * Reverse encodePayload() + * + * @param string $stored + * @return string + */ + private function decodePayload(string $stored): string + { + if (!str_starts_with($stored, self::ENCODED_PREFIX)) { + return $stored; + } + + return (string) base64_decode(substr($stored, strlen(self::ENCODED_PREFIX)), true); + } + + /** + * SQLSTATE class 23 covers integrity constraint violations + * + * @param PDOException $e + * @return bool + */ + private function isConstraintViolation(PDOException $e): bool + { + return str_starts_with((string) $e->getCode(), '23'); + } + + /** + * Fetched fresh on every call: the worker drops and reopens database + * connections around forks, so a cached handle could be a dead one. + * + * @return PDO + */ + private function pdo(): PDO + { + return Database::getPdoInstance($this->config['connection'] ?? null); + } + + /** + * Get the queue job table + * + * @return string + */ + private function table(): string + { + return (string) ($this->config['table'] ?? 'queue_jobs'); + } + + /** + * Get the failed job table + * + * @return string + */ + private function failedTable(): string + { + return (string) ($this->config['failed_table'] ?? 'failed_jobs'); + } +} diff --git a/src/Drivers/MemoryDriver.php b/src/Drivers/MemoryDriver.php new file mode 100644 index 0000000..64c7206 --- /dev/null +++ b/src/Drivers/MemoryDriver.php @@ -0,0 +1,333 @@ +> + */ + private array $jobs = []; + + /** + * @var array unique key => job id + */ + private array $unique = []; + + /** + * @var array + */ + private array $failed = []; + + private int $sequence = 0; + + private int $failedSequence = 0; + + /** + * @inheritDoc + */ + public function push(Envelope $envelope): bool + { + if ($envelope->uniqueKey !== null && isset($this->unique[$envelope->uniqueKey])) { + return false; + } + + $this->jobs[$envelope->id] = [ + 'queue' => $envelope->queue, + 'payload' => $envelope->payload, + 'attempts' => 0, + 'priority' => $envelope->priority, + 'available_at' => $envelope->availableAt, + 'reserved_at' => null, + 'lease_expires_at' => null, + 'unique' => $envelope->uniqueKey, + 'seq' => ++$this->sequence, + ]; + + if ($envelope->uniqueKey !== null) { + $this->unique[$envelope->uniqueKey] = $envelope->id; + } + + return true; + } + + /** + * @inheritDoc + */ + public function pushMany(array $envelopes): int + { + $stored = 0; + + foreach ($envelopes as $envelope) { + $stored += $this->push($envelope) ? 1 : 0; + } + + return $stored; + } + + /** + * @inheritDoc + */ + public function pop(string|array $queues, ?int $leaseFor = null): ?ReservedJob + { + $now = $this->now(); + $lease = $this->leaseSeconds($leaseFor); + + foreach ($this->queueList($queues) as $queue) { + $candidates = array_filter( + $this->jobs, + fn(array $job): bool => $job['queue'] === $queue + && $job['available_at'] <= $now + && $this->isClaimable($job, $now) + ); + + if ($candidates === []) { + continue; + } + + uasort($candidates, fn(array $a, array $b): int => [$b['priority'], $a['seq']] <=> [$a['priority'], $b['seq']]); + + $id = (string) array_key_first($candidates); + + $this->jobs[$id]['attempts']++; + $this->jobs[$id]['reserved_at'] = $now; + $this->jobs[$id]['lease_expires_at'] = $now + $lease; + + return new ReservedJob( + $id, + $queue, + $this->jobs[$id]['payload'], + $this->jobs[$id]['attempts'], + $now, + $now + $lease + ); + } + + return null; + } + + /** + * @inheritDoc + */ + public function delete(ReservedJob $job): bool + { + if (!$this->holds($job)) { + return false; + } + + $this->forget((string) $job->id); + + return true; + } + + /** + * @inheritDoc + */ + public function release(ReservedJob $job, int $delay = 0): bool + { + if (!$this->holds($job)) { + return false; + } + + $id = (string) $job->id; + + $this->jobs[$id]['reserved_at'] = null; + $this->jobs[$id]['lease_expires_at'] = null; + $this->jobs[$id]['available_at'] = $this->now() + max(0, $delay); + + return true; + } + + /** + * @inheritDoc + */ + public function extend(ReservedJob $job, int $seconds): bool + { + if (!$this->holds($job)) { + return false; + } + + $this->jobs[(string) $job->id]['lease_expires_at'] = $this->now() + $seconds; + + return true; + } + + /** + * @inheritDoc + */ + public function fail(ReservedJob $job, string $exception): bool + { + if (!$this->holds($job)) { + return false; + } + + $id = ++$this->failedSequence; + + $this->failed[$id] = new FailedJobRecord( + $id, + $this->config['name'] ?? 'memory', + $this->jobs[(string) $job->id]['queue'], + $this->jobs[(string) $job->id]['payload'], + $exception, + $this->now() + ); + + $this->forget((string) $job->id); + + return true; + } + + /** + * @inheritDoc + */ + public function size(string $queue): int + { + $stats = $this->stats($queue); + + return $stats['ready'] + $stats['delayed']; + } + + /** + * @inheritDoc + */ + public function stats(string $queue): array + { + $now = $this->now(); + $stats = ['ready' => 0, 'delayed' => 0, 'reserved' => 0]; + + foreach ($this->jobs as $job) { + if ($job['queue'] !== $queue) { + continue; + } + + if (!$this->isClaimable($job, $now)) { + $stats['reserved']++; + } elseif ($job['available_at'] > $now) { + $stats['delayed']++; + } else { + $stats['ready']++; + } + } + + return $stats; + } + + /** + * @inheritDoc + */ + public function queues(): array + { + $names = array_values(array_unique(array_column($this->jobs, 'queue'))); + sort($names); + + return $names; + } + + /** + * @inheritDoc + */ + public function clear(string $queue): int + { + $cleared = 0; + + foreach ($this->jobs as $id => $job) { + if ($job['queue'] === $queue) { + $this->forget((string) $id); + $cleared++; + } + } + + return $cleared; + } + + /** + * @inheritDoc + */ + public function failedJobs(): array + { + return array_reverse(array_values($this->failed)); + } + + /** + * @inheritDoc + */ + public function findFailed(string|int $id): ?FailedJobRecord + { + return $this->failed[(int) $id] ?? null; + } + + /** + * @inheritDoc + */ + public function forgetFailed(string|int $id): bool + { + if (!isset($this->failed[(int) $id])) { + return false; + } + + unset($this->failed[(int) $id]); + + return true; + } + + /** + * @inheritDoc + */ + public function flushFailed(): int + { + $count = count($this->failed); + $this->failed = []; + + return $count; + } + + /** + * @inheritDoc + */ + public function countFailed(): int + { + return count($this->failed); + } + + /** + * A job can be claimed when it is unreserved or its lease has run out + * + * @param array $job + * @param int $now + * @return bool + */ + private function isClaimable(array $job, int $now): bool + { + return $job['reserved_at'] === null || $job['lease_expires_at'] <= $now; + } + + /** + * Whether the given reservation is still the live one for its job + * + * @param ReservedJob $job + * @return bool + */ + private function holds(ReservedJob $job): bool + { + $id = (string) $job->id; + + return isset($this->jobs[$id]) + && $this->jobs[$id]['reserved_at'] !== null + && $this->jobs[$id]['attempts'] === $job->attempts; + } + + private function forget(string $id): void + { + $key = $this->jobs[$id]['unique'] ?? null; + + if ($key !== null && ($this->unique[$key] ?? null) === $id) { + unset($this->unique[$key]); + } + + unset($this->jobs[$id]); + } +} diff --git a/src/Drivers/RedisDriver.php b/src/Drivers/RedisDriver.php new file mode 100644 index 0000000..4cab8ad --- /dev/null +++ b/src/Drivers/RedisDriver.php @@ -0,0 +1,579 @@ + script name => sha1 + */ + private array $shas = []; + + /** + * @param array $config + * @param Client|null $client + * @param (Closure(): int)|null $clock + */ + public function __construct(array $config = [], ?Client $client = null, ?Closure $clock = null) + { + parent::__construct($config, $clock); + + if ($client === null && !class_exists(Client::class)) { + throw new \RuntimeException( + 'The "redis" queue driver requires predis/predis. Install it with: composer require predis/predis' + ); + } + + $this->client = $client ?? new Client( + $config['connection'] ?? 'redis://127.0.0.1:6379', + $config['options'] ?? [] + ); + + // The braces are a Redis Cluster hash tag: they keep every key on one slot. + $this->prefix = (string) ($config['prefix'] ?? '{doppar_queue}'); + } + + /** + * @inheritDoc + */ + public function push(Envelope $envelope): bool + { + return $this->script( + 'push', + [ + $this->job($envelope->id), + $this->key('q', $envelope->queue, 'ready'), + $this->key('q', $envelope->queue, 'delayed'), + $this->key('queues'), + $this->key('seq'), + $this->key('unique', $envelope->uniqueKey ?? '-'), + ], + [ + $envelope->id, + $envelope->queue, + $envelope->payload, + $envelope->priority, + $envelope->availableAt, + $envelope->createdAt ?: $this->now(), + $envelope->uniqueKey ?? '', + $this->now(), + ] + ) === 1; + } + + /** + * @inheritDoc + */ + public function pushMany(array $envelopes): int + { + $stored = 0; + + foreach ($envelopes as $envelope) { + $stored += $this->push($envelope) ? 1 : 0; + } + + return $stored; + } + + /** + * @inheritDoc + */ + public function pop(string|array $queues, ?int $leaseFor = null): ?ReservedJob + { + $now = $this->now(); + $lease = $this->leaseSeconds($leaseFor); + + foreach ($this->queueList($queues) as $queue) { + $claimed = $this->script( + 'pop', + [ + $this->key('q', $queue, 'ready'), + $this->key('q', $queue, 'delayed'), + $this->key('q', $queue, 'reserved'), + ], + [$now, $lease, $this->key('job') . ':', self::PROMOTE_LIMIT, self::SCORE_BAND] + ); + + if (is_array($claimed)) { + return new ReservedJob( + (string) $claimed[0], + (string) $claimed[1], + (string) $claimed[2], + (int) $claimed[3], + $now, + $now + $lease + ); + } + } + + return null; + } + + /** + * @inheritDoc + */ + public function delete(ReservedJob $job): bool + { + return $this->script( + 'delete', + [$this->job((string) $job->id), $this->key('q', $job->queue, 'reserved'), $this->key('q', $job->queue, 'ready'), $this->key('q', $job->queue, 'delayed')], + [(string) $job->id, $job->attempts, $this->key('unique') . ':'] + ) === 1; + } + + /** + * @inheritDoc + */ + public function release(ReservedJob $job, int $delay = 0): bool + { + return $this->script( + 'release', + [$this->job((string) $job->id), $this->key('q', $job->queue, 'reserved'), $this->key('q', $job->queue, 'ready'), $this->key('q', $job->queue, 'delayed')], + [(string) $job->id, $job->attempts, max(0, $delay), $this->now(), self::SCORE_BAND] + ) === 1; + } + + /** + * @inheritDoc + */ + public function extend(ReservedJob $job, int $seconds): bool + { + return $this->script( + 'extend', + [$this->job((string) $job->id), $this->key('q', $job->queue, 'reserved')], + [(string) $job->id, $job->attempts, $this->now() + $seconds] + ) === 1; + } + + /** + * @inheritDoc + */ + public function fail(ReservedJob $job, string $exception): bool + { + return $this->script( + 'fail', + [ + $this->job((string) $job->id), + $this->key('q', $job->queue, 'reserved'), + $this->key('failed'), + $this->key('failed', 'seq'), + ], + [ + (string) $job->id, + $job->attempts, + $this->key('unique') . ':', + $exception, + $this->now(), + $this->key('failed') . ':', + (string) ($this->config['name'] ?? 'redis'), + ] + ) > 0; + } + + /** + * @inheritDoc + */ + public function size(string $queue): int + { + $stats = $this->stats($queue); + + return $stats['ready'] + $stats['delayed']; + } + + /** + * @inheritDoc + */ + public function stats(string $queue): array + { + $counts = $this->script( + 'stats', + [ + $this->key('q', $queue, 'ready'), + $this->key('q', $queue, 'delayed'), + $this->key('q', $queue, 'reserved'), + ], + [$this->now()] + ); + + return [ + 'ready' => (int) ($counts[0] ?? 0), + 'delayed' => (int) ($counts[1] ?? 0), + 'reserved' => (int) ($counts[2] ?? 0), + ]; + } + + /** + * @inheritDoc + */ + public function queues(): array + { + $names = []; + + foreach ($this->client->smembers($this->key('queues')) as $queue) { + $stats = $this->stats((string) $queue); + + if ($stats['ready'] + $stats['delayed'] + $stats['reserved'] > 0) { + $names[] = (string) $queue; + } + } + + sort($names); + + return $names; + } + + /** + * @inheritDoc + */ + public function clear(string $queue): int + { + $cleared = 0; + + do { + $batch = (int) $this->script( + 'clear', + [ + $this->key('q', $queue, 'ready'), + $this->key('q', $queue, 'delayed'), + $this->key('q', $queue, 'reserved'), + ], + [$this->key('job') . ':', $this->key('unique') . ':', self::CLEAR_BATCH] + ); + + $cleared += $batch; + } while ($batch >= self::CLEAR_BATCH); + + return $cleared; + } + + /** + * @inheritDoc + */ + public function failedJobs(): array + { + $rows = $this->script('failed_list', [$this->key('failed')], [$this->key('failed') . ':']); + + $records = []; + + foreach (is_array($rows) ? $rows : [] as [$id, $flat]) { + $fields = []; + + for ($i = 0, $count = count($flat); $i < $count; $i += 2) { + $fields[$flat[$i]] = $flat[$i + 1]; + } + + $records[] = $this->failedRecord((int) $id, $fields); + } + + return $records; + } + + /** + * @inheritDoc + */ + public function findFailed(string|int $id): ?FailedJobRecord + { + $row = $this->client->hgetall($this->key('failed', (string) $id)); + + return empty($row) ? null : $this->failedRecord((int) $id, $row); + } + + /** + * @inheritDoc + */ + public function forgetFailed(string|int $id): bool + { + $removed = (int) $this->client->zrem($this->key('failed'), (string) $id); + $this->client->del([$this->key('failed', (string) $id)]); + + return $removed === 1; + } + + /** + * @inheritDoc + */ + public function flushFailed(): int + { + $ids = $this->client->zrange($this->key('failed'), 0, -1); + + foreach ($ids as $id) { + $this->forgetFailed($id); + } + + return count($ids); + } + + /** + * @inheritDoc + */ + public function countFailed(): int + { + return (int) $this->client->zcard($this->key('failed')); + } + + /** + * @param int $id + * @param array $row + * @return FailedJobRecord + */ + private function failedRecord(int $id, array $row): FailedJobRecord + { + return new FailedJobRecord( + $id, + $row['connection'] ?? '', + $row['queue'] ?? '', + $row['payload'] ?? '', + $row['exception'] ?? '', + (int) ($row['failed_at'] ?? 0) + ); + } + + /** + * Build a key under the driver prefix + * + * @param string ...$parts + * @return string + */ + private function key(string ...$parts): string + { + return $this->prefix . ':' . implode(':', $parts); + } + + private function job(string $id): string + { + return $this->key('job', $id); + } + + /** + * Run a named Lua script, by SHA with a fallback to sending the source + * when the server has not cached it yet + * + * @param string $name + * @param array $keys + * @param array $args + * @return mixed + */ + private function script(string $name, array $keys, array $args): mixed + { + $source = self::SCRIPTS[$name]; + $sha = $this->shas[$name] ??= sha1($source); + $arguments = array_merge($keys, $args); + + try { + return $this->client->evalsha($sha, count($keys), ...$arguments); + } catch (ServerException $e) { + if (!str_contains($e->getMessage(), 'NOSCRIPT')) { + throw $e; + } + + return $this->client->eval($source, count($keys), ...$arguments); + } + } + + /** + * Lua sources. Shared fragments are written into each script so that every + * script stays a single self-contained unit. + */ + private const SCRIPTS = [ + // KEYS: job, ready, delayed, queues, seq, unique + // ARGV: id, queue, payload, priority, availableAt, createdAt, uniqueKey, now + 'push' => <<<'LUA' +if ARGV[7] ~= '' then + if not redis.call('SET', KEYS[6], ARGV[1], 'NX') then + return 0 + end +end +local seq = redis.call('INCR', KEYS[5]) +redis.call('HSET', KEYS[1], 'queue', ARGV[2], 'payload', ARGV[3], 'attempts', 0, + 'priority', ARGV[4], 'seq', seq, 'created_at', ARGV[6], 'unique', ARGV[7]) +redis.call('SADD', KEYS[4], ARGV[2]) +if tonumber(ARGV[5]) <= tonumber(ARGV[8]) then + redis.call('ZADD', KEYS[2], string.format('%.0f', (100 - tonumber(ARGV[4])) * 1000000000000 + seq), ARGV[1]) +else + redis.call('ZADD', KEYS[3], ARGV[5], ARGV[1]) +end +return 1 +LUA, + + // KEYS: ready, delayed, reserved + // ARGV: now, lease, jobPrefix, promoteLimit, scoreBand + 'pop' => <<<'LUA' +local now = tonumber(ARGV[1]) +local lease = tonumber(ARGV[2]) +local prefix = ARGV[3] +local band = tonumber(ARGV[5]) + +local function score(id) + local h = redis.call('HMGET', prefix .. id, 'priority', 'seq') + if not h[1] or not h[2] then return nil end + return string.format('%.0f', (100 - tonumber(h[1])) * band + tonumber(h[2])) +end + +local function promote(from) + local ids = redis.call('ZRANGEBYSCORE', from, '-inf', now, 'LIMIT', 0, tonumber(ARGV[4])) + for _, id in ipairs(ids) do + redis.call('ZREM', from, id) + local s = score(id) + if s then redis.call('ZADD', KEYS[1], s, id) end + end +end + +promote(KEYS[2]) +promote(KEYS[3]) + +while true do + local top = redis.call('ZRANGE', KEYS[1], 0, 0) + if #top == 0 then return false end + local id = top[1] + redis.call('ZREM', KEYS[1], id) + local h = redis.call('HGETALL', prefix .. id) + if #h > 0 then + local attempts = redis.call('HINCRBY', prefix .. id, 'attempts', 1) + redis.call('HSET', prefix .. id, 'reserved_at', now) + redis.call('ZADD', KEYS[3], now + lease, id) + local payload, queue = '', '' + for i = 1, #h, 2 do + if h[i] == 'payload' then payload = h[i + 1] elseif h[i] == 'queue' then queue = h[i + 1] end + end + return {id, queue, payload, attempts} + end +end +LUA, + + // KEYS: job, reserved, ready, delayed + // ARGV: id, attempts, uniquePrefix + 'delete' => <<<'LUA' +local attempts = redis.call('HGET', KEYS[1], 'attempts') +if not attempts or attempts ~= ARGV[2] then return 0 end +if not redis.call('ZSCORE', KEYS[2], ARGV[1]) then return 0 end +local u = redis.call('HGET', KEYS[1], 'unique') +if u and u ~= '' and redis.call('GET', ARGV[3] .. u) == ARGV[1] then + redis.call('DEL', ARGV[3] .. u) +end +redis.call('ZREM', KEYS[2], ARGV[1]) +redis.call('ZREM', KEYS[3], ARGV[1]) +redis.call('ZREM', KEYS[4], ARGV[1]) +redis.call('DEL', KEYS[1]) +return 1 +LUA, + + // KEYS: job, reserved, ready, delayed + // ARGV: id, attempts, delay, now, scoreBand + 'release' => <<<'LUA' +local attempts = redis.call('HGET', KEYS[1], 'attempts') +if not attempts or attempts ~= ARGV[2] then return 0 end +if not redis.call('ZSCORE', KEYS[2], ARGV[1]) then return 0 end +redis.call('ZREM', KEYS[2], ARGV[1]) +redis.call('HDEL', KEYS[1], 'reserved_at') +local delay = tonumber(ARGV[3]) +if delay > 0 then + redis.call('ZADD', KEYS[4], tonumber(ARGV[4]) + delay, ARGV[1]) +else + local h = redis.call('HMGET', KEYS[1], 'priority', 'seq') + redis.call('ZADD', KEYS[3], string.format('%.0f', (100 - tonumber(h[1])) * tonumber(ARGV[5]) + tonumber(h[2])), ARGV[1]) +end +return 1 +LUA, + + // KEYS: job, reserved + // ARGV: id, attempts, leaseUntil + 'extend' => <<<'LUA' +local attempts = redis.call('HGET', KEYS[1], 'attempts') +if not attempts or attempts ~= ARGV[2] then return 0 end +if not redis.call('ZSCORE', KEYS[2], ARGV[1]) then return 0 end +redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1]) +return 1 +LUA, + + // KEYS: job, reserved, failedIndex, failedSeq + // ARGV: id, attempts, uniquePrefix, exception, now, failedPrefix, connection + 'fail' => <<<'LUA' +local attempts = redis.call('HGET', KEYS[1], 'attempts') +if not attempts or attempts ~= ARGV[2] then return 0 end +if not redis.call('ZSCORE', KEYS[2], ARGV[1]) then return 0 end +local h = redis.call('HMGET', KEYS[1], 'queue', 'payload', 'unique') +local fid = redis.call('INCR', KEYS[4]) +redis.call('HSET', ARGV[6] .. fid, 'connection', ARGV[7], 'queue', h[1], 'payload', h[2], + 'exception', ARGV[4], 'failed_at', ARGV[5]) +redis.call('ZADD', KEYS[3], ARGV[5], fid) +if h[3] and h[3] ~= '' and redis.call('GET', ARGV[3] .. h[3]) == ARGV[1] then + redis.call('DEL', ARGV[3] .. h[3]) +end +redis.call('ZREM', KEYS[2], ARGV[1]) +redis.call('DEL', KEYS[1]) +return fid +LUA, + + // KEYS: ready, delayed, reserved + // ARGV: now + 'stats' => <<<'LUA' +local now = ARGV[1] +return { + redis.call('ZCARD', KEYS[1]) + redis.call('ZCOUNT', KEYS[2], '-inf', now) + redis.call('ZCOUNT', KEYS[3], '-inf', now), + redis.call('ZCOUNT', KEYS[2], '(' .. now, '+inf'), + redis.call('ZCOUNT', KEYS[3], '(' .. now, '+inf') +} +LUA, + + // KEYS: failedIndex + // ARGV: failedPrefix + 'failed_list' => <<<'LUA' +local out = {} +local ids = redis.call('ZREVRANGE', KEYS[1], 0, -1) +for _, id in ipairs(ids) do + local h = redis.call('HGETALL', ARGV[1] .. id) + if #h > 0 then out[#out + 1] = {id, h} end +end +return out +LUA, + + // KEYS: ready, delayed, reserved + // ARGV: jobPrefix, uniquePrefix, batch + 'clear' => <<<'LUA' +local removed = 0 +local batch = tonumber(ARGV[3]) +for k = 1, 3 do + if removed >= batch then break end + local ids = redis.call('ZRANGE', KEYS[k], 0, batch - removed - 1) + for _, id in ipairs(ids) do + local u = redis.call('HGET', ARGV[1] .. id, 'unique') + if u and u ~= '' and redis.call('GET', ARGV[2] .. u) == id then + redis.call('DEL', ARGV[2] .. u) + end + redis.call('DEL', ARGV[1] .. id) + redis.call('ZREM', KEYS[k], id) + removed = removed + 1 + end +end +return removed +LUA, + ]; +} diff --git a/src/Exceptions/MaxAttemptsExceededException.php b/src/Exceptions/MaxAttemptsExceededException.php new file mode 100644 index 0000000..998f853 --- /dev/null +++ b/src/Exceptions/MaxAttemptsExceededException.php @@ -0,0 +1,5 @@ + $jobs, ?string $connection = null) + * @method static ReservedJob|null pop(string|array $queue = 'default', ?string $connection = null) + * @method static bool delete(ReservedJob $queueJob, ?string $connection = null) + * @method static bool release(ReservedJob $queueJob, int $delay = 0, ?string $connection = null) + * @method static bool extendLease(ReservedJob $queueJob, int $seconds, ?string $connection = null) + * @method static void markAsFailed(ReservedJob $queueJob, \Throwable $exception, ?string $connection = null) + * @method static int size(string $queue = 'default', ?string $connection = null) + * @method static array{ready: int, delayed: int, reserved: int} stats(string $queue = 'default', ?string $connection = null) + * @method static int clear(string $queue = 'default', ?string $connection = null) + * @method static bool retryFailed(FailedJobRecord|string|int $failed, ?string $connection = null) * @method static void setDefaultQueue(string $queue) * @method static string getDefaultQueue() + * + * @see QueueManager */ class Queue extends BaseFacade { diff --git a/src/InteractsWithModelSerialization.php b/src/InteractsWithModelSerialization.php index 1ca9483..7684c0d 100644 --- a/src/InteractsWithModelSerialization.php +++ b/src/InteractsWithModelSerialization.php @@ -33,8 +33,10 @@ public function __serialize(): array $properties = $reflection->getProperties(); foreach ($properties as $property) { - - if (!$property->isInitialized($this)) { + // Static properties belong to the class, not the instance. Serializing + // them would freeze their value into the payload and write it back over + // the live value when the worker unserializes the job. + if ($property->isStatic() || !$property->isInitialized($this)) { continue; } @@ -79,6 +81,11 @@ public function __unserialize(array $values): void $property = $reflection->getProperty($name); + // Payloads written before static properties were excluded may still carry them. + if ($property->isStatic()) { + continue; + } + // Restore serialized models if (is_array($value) && isset($value['__serialized_model__'])) { $property->setValue($this, $this->restoreModel($value)); @@ -369,7 +376,7 @@ public function __wakeup(): void $reflection = new \ReflectionClass($this); foreach ($reflection->getProperties() as $property) { - if ($property->isInitialized($this)) { + if (!$property->isStatic() && $property->isInitialized($this)) { $values[$property->getName()] = $property->getValue($this); } } diff --git a/src/InteractsWithQueueableAttributes.php b/src/InteractsWithQueueableAttributes.php index 209cdca..cdcf6c7 100644 --- a/src/InteractsWithQueueableAttributes.php +++ b/src/InteractsWithQueueableAttributes.php @@ -51,6 +51,18 @@ protected function applyQueueableAttributes(): void if ($attribute->timeout !== null) { $this->timeout = $attribute->timeout; } + + if ($attribute->priority !== null) { + $this->priority = $attribute->priority; + } + + if ($attribute->onConnection !== null) { + $this->connection = $attribute->onConnection; + } + + if ($attribute->backoff !== null) { + $this->backoff = $attribute->backoff; + } } } } \ No newline at end of file diff --git a/src/Job.php b/src/Job.php index b5a0502..8211da4 100644 --- a/src/Job.php +++ b/src/Job.php @@ -60,6 +60,27 @@ abstract class Job implements JobInterface */ public $timeout = null; + /** + * Higher priority jobs are claimed first within a queue (-100 to 100). + * + * @var int + */ + public int $priority = 0; + + /** + * The queue connection to dispatch on. Null uses the default connection. + * + * @var string|null + */ + public ?string $connection = null; + + /** + * Seconds to wait before each retry + * + * @var int|array|null + */ + public $backoff = null; + /** * Chain identifier (if this job is part of a chain). * @@ -167,6 +188,72 @@ public function setJobId(string $id): void $this->jobId = $id; } + /** + * Get the priority of the job. + * + * @return int + */ + public function priority(): int + { + return $this->priority; + } + + /** + * Get the queue connection the job is dispatched on, or null for the default. + * + * @return string|null + */ + public function connection(): ?string + { + return $this->connection; + } + + /** + * Get the key that makes this job unique, or null when duplicates are allowed. + * + * @return string|null + */ + public function uniqueId(): ?string + { + return null; + } + + /** + * Get the retry backoff. + * + * @return int|array|null + */ + public function backoff(): int|array|null + { + return $this->backoff; + } + + /** + * Set the priority of the job. + * + * @param int $priority + * @return self + */ + public function withPriority(int $priority): self + { + $this->priority = $priority; + + return $this; + } + + /** + * Set the queue connection of the job. + * + * @param string $connection + * @return self + */ + public function onConnection(string $connection): self + { + $this->connection = $connection; + + return $this; + } + /** * Set the queue name. * @@ -225,9 +312,9 @@ public function dispatch(): ?string * Dispatch the job to the queue after a delay. * * @param int $delay Delay in seconds - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public function dispatchAfter(int $delay): string + public function dispatchAfter(int $delay): ?string { $this->delayFor($delay); @@ -240,9 +327,9 @@ public function dispatchAfter(int $delay): string * Dispatch the job to a specific queue. * * @param string $queue - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public function dispatchOn(string $queue): string + public function dispatchOn(string $queue): ?string { $this->onQueue($queue); @@ -255,9 +342,9 @@ public function dispatchOn(string $queue): string * Dispatch the job. * * @param mixed ...$args - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public static function dispatchNow(...$args): string + public static function dispatchNow(...$args): ?string { $job = new static(...$args); @@ -280,9 +367,9 @@ public static function dispatchSync(...$args): void /** * Force the job to be queued even without Queueable attribute. * - * @return string Job ID + * @return string|null Job ID, or null when refused as a duplicate of a unique job */ - public function forceQueue(): string + public function forceQueue(): ?string { $this->applyQueueableAttributes(); @@ -362,6 +449,10 @@ public function dispatchNextChainJob(): void $nextJob->chainOnFailure = $this->chainOnFailure; $nextJob->queueName = $this->queueName; + if ($nextJob instanceof self) { + $nextJob->connection ??= $this->connection; + } + // Push the next job to queue Queue::push($nextJob); } diff --git a/src/QueueLauncher.php b/src/QueueLauncher.php index aa5c182..182060f 100644 --- a/src/QueueLauncher.php +++ b/src/QueueLauncher.php @@ -21,7 +21,10 @@ class QueueLauncher extends ServiceLauncher implements GhostableLauncher */ public function register(): void { - $this->app->singleton('queue.worker', QueueManager::class); + $this->mergeConfig(__DIR__ . '/../config/queue.php', 'queue'); + + $this->app->singleton(QueueManager::class, fn(): QueueManager => new QueueManager()); + $this->app->alias(QueueManager::class, 'queue.worker'); } /** @@ -37,6 +40,10 @@ public function launch(): void __DIR__ . '/database/migrations' => schema_path('migrations'), ], 'migrations'); + $this->publishes([ + __DIR__ . '/../config/queue.php' => config_path('queue.php'), + ], 'config'); + $this->commands([ QueueRunCommand::class, QueueRetryCommand::class, @@ -56,6 +63,7 @@ public function ghosts(): array { return [ 'queue.worker', + QueueManager::class, ]; } } diff --git a/src/QueueManager.php b/src/QueueManager.php index ec30b4e..f127e6e 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -2,10 +2,16 @@ namespace Doppar\Queue; -use Doppar\Queue\Models\FailedJob; -use Doppar\Queue\Models\QueueJob; -use Doppar\Queue\Exceptions\QueueException; +use Closure; use Doppar\Queue\Contracts\JobInterface; +use Doppar\Queue\Contracts\QueueDriver; +use Doppar\Queue\Drivers\DatabaseDriver; +use Doppar\Queue\Drivers\MemoryDriver; +use Doppar\Queue\Drivers\RedisDriver; +use Doppar\Queue\Exceptions\QueueException; +use Doppar\Queue\Support\Envelope; +use Doppar\Queue\Support\FailedJobRecord; +use Doppar\Queue\Support\ReservedJob; class QueueManager { @@ -16,135 +22,270 @@ class QueueManager */ protected $defaultQueue = 'default'; + /** + * @var array|null + */ + protected ?array $config; + + /** + * @var Closure(): int + */ + protected Closure $clock; + + /** + * Resolved connections + * + * @var array + */ + protected array $connections = []; + + /** + * Custom driver factories registered with extend() + * + * @var array, Closure): QueueDriver> + */ + protected array $customDrivers = []; + + /** + * @param array|null $config + * @param (Closure(): int)|null $clock + */ + public function __construct(?array $config = null, ?Closure $clock = null) + { + $this->config = $config; + $this->clock = $clock ?? static fn(): int => time(); + } + + /** + * Get a queue driver by connection name, or the default connection. + * + * @param string|null $name + * @return QueueDriver + * @throws QueueException + */ + public function connection(?string $name = null): QueueDriver + { + $name ??= $this->getDefaultConnection(); + + return $this->connections[$name] ??= $this->makeDriver($name); + } + + /** + * Get the default connection name. + * + * @return string + */ + public function getDefaultConnection(): string + { + return (string) ($this->config()['default'] ?? 'database'); + } + + /** + * Register a factory for a custom driver name + * + * @param string $driver + * @param Closure(array, Closure): QueueDriver $factory + * @return void + */ + public function extend(string $driver, Closure $factory): void + { + $this->customDrivers[$driver] = $factory; + $this->connections = []; + } + + /** + * Drop a resolved connection (or all of them) so it is rebuilt on next use. + * + * @param string|null $name + * @return void + */ + public function purge(?string $name = null): void + { + if ($name === null) { + $this->connections = []; + + return; + } + + unset($this->connections[$name]); + } + /** * Push a job onto the queue. * * @param JobInterface $job - * @return string Job ID + * @param string|null $connection + * @return string|null Job ID, or null when refused as a duplicate of a unique job * @throws QueueException */ - public function push(JobInterface $job): string + public function push(JobInterface $job, ?string $connection = null): ?string { try { - $jobId = $this->generateJobId(); - $job->setJobId($jobId); + $envelope = $this->envelopeFor($job); + $stored = $this->connection($this->connectionFor($job, $connection))->push($envelope); - $payload = $this->createPayload($job); - $availableAt = time() + $job->delay(); - - QueueJob::create([ - 'queue' => $job->queue(), - 'payload' => $payload, - 'attempts' => 0, - 'reserved_at' => null, - 'available_at' => $availableAt, - 'created_at' => time(), - ]); - - return $jobId; + return $stored ? $envelope->id : null; } catch (\Throwable $e) { throw new QueueException("Failed to push job to queue: " . $e->getMessage(), 0, $e); } } /** - * Pop the next job off the queue. + * Push many jobs with as few round trips as the driver allows. * - * @param string $queue - * @return QueueJob|null + * @param array $jobs + * @param string|null $connection + * @return int + * @throws QueueException */ - public function pop(string $queue = 'default'): ?QueueJob + public function pushMany(array $jobs, ?string $connection = null): int { try { - $job = QueueJob::available($queue)->first(); + $grouped = []; + + foreach ($jobs as $job) { + $grouped[$this->connectionFor($job, $connection) ?? $this->getDefaultConnection()][] = $this->envelopeFor($job); + } + + $stored = 0; - if ($job) { - $job->reserve(); + foreach ($grouped as $name => $envelopes) { + $stored += $this->connection($name)->pushMany($envelopes); } - return $job; + return $stored; } catch (\Throwable $e) { - return null; + throw new QueueException("Failed to push jobs to queue: " . $e->getMessage(), 0, $e); } } + /** + * Claim the next job. Accepts a queue name, a comma separated list, or an + * array; queues are tried in the order given. + * + * @param string|array $queue + * @param string|null $connection + * @return ReservedJob|null + */ + public function pop(string|array $queue = 'default', ?string $connection = null): ?ReservedJob + { + return $this->connection($connection)->pop($queue); + } + /** * Delete a job from the queue. * - * @param QueueJob $queueJob + * @param ReservedJob $queueJob + * @param string|null $connection * @return bool */ - public function delete(QueueJob $queueJob): bool + public function delete(ReservedJob $queueJob, ?string $connection = null): bool { - try { - return $queueJob->deleteJob(); - } catch (\Throwable $e) { - return false; - } + return $this->connection($connection)->delete($queueJob); } /** * Release a job back to the queue. * - * @param QueueJob $queueJob + * @param ReservedJob $queueJob * @param int $delay + * @param string|null $connection * @return bool */ - public function release(QueueJob $queueJob, int $delay = 0): bool + public function release(ReservedJob $queueJob, int $delay = 0, ?string $connection = null): bool { - try { - return $queueJob->release($delay); - } catch (\Throwable $e) { - return false; - } + return $this->connection($connection)->release($queueJob, $delay); + } + + /** + * Extend the lease of a job that is still being worked on. + * + * @param ReservedJob $queueJob + * @param int $seconds + * @param string|null $connection + * @return bool + */ + public function extendLease(ReservedJob $queueJob, int $seconds, ?string $connection = null): bool + { + return $this->connection($connection)->extend($queueJob, $seconds); } /** - * Move a job to the failed jobs table. + * Move a job to the failed store. * - * @param QueueJob $queueJob + * @param ReservedJob $queueJob * @param \Throwable $exception + * @param string|null $connection * @return void */ - public function markAsFailed(QueueJob $queueJob, \Throwable $exception): void + public function markAsFailed(ReservedJob $queueJob, \Throwable $exception, ?string $connection = null): void { - try { - FailedJob::create([ - 'connection' => 'database', - 'queue' => $queueJob->queue, - 'payload' => $queueJob->payload, - 'exception' => $this->formatException($exception), - 'failed_at' => time(), - ]); - - $this->delete($queueJob); - } catch (\Throwable $e) { - error("Failed to mark job as failed: " . $e->getMessage()); - } + $this->connection($connection)->fail($queueJob, $this->formatException($exception)); } /** - * Get the count of jobs in a queue. + * Get the count of jobs in a queue that are not reserved. * * @param string $queue + * @param string|null $connection * @return int */ - public function size(string $queue = 'default'): int + public function size(string $queue = 'default', ?string $connection = null): int { - return QueueJob::where('queue', $queue) - ->whereNull('reserved_at') - ->count(); + return $this->connection($connection)->size($queue); + } + + /** + * Break a queue down into ready, delayed and reserved jobs. + * + * @param string $queue + * @param string|null $connection + * @return array{ready: int, delayed: int, reserved: int} + */ + public function stats(string $queue = 'default', ?string $connection = null): array + { + return $this->connection($connection)->stats($queue); } /** * Clear all jobs from a queue. * * @param string $queue + * @param string|null $connection * @return int Number of jobs deleted */ - public function clear(string $queue = 'default'): int + public function clear(string $queue = 'default', ?string $connection = null): int + { + return $this->connection($connection)->clear($queue); + } + + /** + * Put a failed job back on its queue and remove it from the failed store. + * + * @param FailedJobRecord|string|int $failed + * @param string|null $connection + * @return bool + */ + public function retryFailed(FailedJobRecord|string|int $failed, ?string $connection = null): bool { - return QueueJob::where('queue', $queue)->delete(); + $driver = $this->connection($connection); + $record = $failed instanceof FailedJobRecord ? $failed : $driver->findFailed($failed); + + if ($record === null) { + return false; + } + + $job = $this->unserializeJob($record->payload); + + if ($job instanceof Job) { + $job->attempts = 0; + } + + if ($this->push($job, $connection) === null) { + return false; + } + + return $driver->forgetFailed($record->id); } /** @@ -176,11 +317,87 @@ protected function createPayload(JobInterface $job): string public function unserializeJob(string $payload): JobInterface { try { - $data = unserialize($payload); - return $data['job']; + $data = @unserialize($payload); } catch (\Throwable $e) { throw new QueueException("Failed to unserialize job: " . $e->getMessage(), 0, $e); } + + // A payload that is corrupt, is not a job, or names a class that no + // longer exists yields something other than a JobInterface. + if (!is_array($data) || !($data['job'] ?? null) instanceof JobInterface) { + throw new QueueException('Failed to unserialize job: the payload does not contain a valid job.'); + } + + return $data['job']; + } + + /** + * Build the driver-facing envelope for a job, stamping it with a job id. + * + * @param JobInterface $job + * @return Envelope + */ + protected function envelopeFor(JobInterface $job): Envelope + { + $id = $this->generateJobId(); + $job->setJobId($id); + + $now = ($this->clock)(); + + return new Envelope( + $id, + $job->queue(), + $this->createPayload($job), + $now + $job->delay(), + (int) $this->optional($job, 'priority', 0), + $this->uniqueKeyFor($job), + $now + ); + } + + /** + * Get the connection a job asks for, or the given fallback. + * + * @param JobInterface $job + * @param string|null $fallback + * @return string|null + */ + protected function connectionFor(JobInterface $job, ?string $fallback): ?string + { + return $this->optional($job, 'connection', null) ?? $fallback; + } + + /** + * Derive the uniqueness key of a job, or null when it is not unique. + * + * @param JobInterface $job + * @return string|null + */ + protected function uniqueKeyFor(JobInterface $job): ?string + { + $id = $this->optional($job, 'uniqueId', null); + + if ($id === null) { + return null; + } + + $key = $job::class . ':' . $id; + + // Keeps the key inside common index length limits. + return strlen($key) > 191 ? $job::class . ':' . sha1($key) : $key; + } + + /** + * Read an optional job setting + * + * @param JobInterface $job + * @param string $method + * @param mixed $default + * @return mixed + */ + protected function optional(JobInterface $job, string $method, mixed $default): mixed + { + return method_exists($job, $method) ? $job->{$method}() : $default; } /** @@ -231,4 +448,65 @@ public function getDefaultQueue(): string { return $this->defaultQueue; } + + /** + * Build the driver for a named connection. + * + * @param string $name + * @return QueueDriver + * @throws QueueException + */ + protected function makeDriver(string $name): QueueDriver + { + $connection = $this->config()['connections'][$name] ?? null; + + if ($connection === null) { + throw new QueueException("Queue connection [{$name}] is not configured."); + } + + $driver = $connection['driver'] ?? $name; + $connection['name'] = $name; + + if (isset($this->customDrivers[$driver])) { + return ($this->customDrivers[$driver])($connection, $this->clock); + } + + return match ($driver) { + 'database' => new DatabaseDriver($connection, $this->clock), + 'redis' => new RedisDriver($connection, null, $this->clock), + 'memory', 'array' => new MemoryDriver($connection, $this->clock), + default => throw new QueueException("Queue driver [{$driver}] is not supported."), + }; + } + + /** + * Resolve the queue config, filling gaps from the built-in defaults + * + * @return array + */ + protected function config(): array + { + if ($this->config !== null) { + return $this->config; + } + + $defaults = [ + 'default' => 'database', + 'connections' => [ + 'database' => ['driver' => 'database'], + 'redis' => ['driver' => 'redis'], + 'memory' => ['driver' => 'memory'], + ], + ]; + + try { + $configured = config('queue'); + } catch (\Throwable) { + $configured = null; + } + + return $this->config = is_array($configured) + ? array_replace_recursive($defaults, $configured) + : $defaults; + } } diff --git a/src/QueueWorker.php b/src/QueueWorker.php index 3eb4518..77cd335 100644 --- a/src/QueueWorker.php +++ b/src/QueueWorker.php @@ -2,7 +2,8 @@ namespace Doppar\Queue; -use Doppar\Queue\Models\QueueJob; +use Doppar\Queue\Support\ReservedJob; +use Doppar\Queue\Exceptions\MaxAttemptsExceededException; use Doppar\Queue\Exceptions\JobTimeoutException; use Doppar\Queue\Contracts\JobInterface; @@ -79,6 +80,34 @@ class QueueWorker */ protected $insideTimeoutContext = false; + /** + * The queue connection this worker consumes, or null for the default. + * + * @var string|null + */ + protected ?string $connection = null; + + /** + * The job currently being executed, kept so its lease can be renewed. + * + * @var ReservedJob|null + */ + protected ?ReservedJob $reservation = null; + + /** + * Unix time the lease of the current job was last renewed. + * + * @var int + */ + protected int $leaseRenewedAt = 0; + + /** + * Seconds between lease renewals while a job runs in a timeout child process. + * + * @var int + */ + protected int $leaseRenewEvery = 20; + /** * Create a new queue worker. * @@ -111,6 +140,26 @@ public function setOnJobProcessed(callable $callback): void $this->onJobProcessed = $callback; } + /** + * Claim and process at most one job, without sleeping when the queue is empty. + * + * @param string|array $queue A name, a comma separated list, or an array, tried in order + * @return bool + */ + public function runNextJob(string|array $queue = 'default'): bool + { + $queueJob = $this->manager->pop($queue, $this->connection); + + if ($queueJob === null) { + return false; + } + + $this->processJob($queueJob); + $this->jobsProcessed++; + + return true; + } + /** * Run the worker daemon. * @@ -163,7 +212,7 @@ public function daemon(string $queue = 'default', array $options = []): void protected function processNextJob(string $queue): void { try { - $queueJob = $this->manager->pop($queue); + $queueJob = $this->manager->pop($queue, $this->connection); if ($queueJob === null) { $this->sleep($this->sleep); @@ -181,26 +230,63 @@ protected function processNextJob(string $queue): void /** * Process a single job. * - * @param QueueJob $queueJob + * @param ReservedJob $queueJob * @return void */ - protected function processJob(QueueJob $queueJob): void + protected function processJob(ReservedJob $queueJob): void { + $job = null; + try { // Unserialize the job $job = $this->manager->unserializeJob($queueJob->payload); $job->attempts = $queueJob->attempts; + // A job whose lease expired mid-run comes back with its attempts + // already spent; do not run it again. + $maxAttempts = max(1, $job->tries()); + + if ($queueJob->attempts > $maxAttempts) { + throw new MaxAttemptsExceededException( + "Job has been attempted too many times ({$queueJob->attempts} of {$maxAttempts})." + ); + } + if (is_callable($this->onJobProcessing)) { ($this->onJobProcessing)($job); } + $this->reservation = $queueJob; + $this->leaseRenewedAt = time(); + // Execute the job with timeout $this->executeJobWithTimeout($job); + } catch (\Throwable $e) { + $this->reservation = null; + $this->handleJobException($queueJob, $job, $e); + + return; + } + + $this->reservation = null; + + // The job ran. If it cannot be removed, its lease will expire and it will + // run again (at-least-once); that is not a job failure, and the follow-up + // work below is left to the run that finally removes it, so a chain is + // never advanced twice. + try { + if (!$this->manager->delete($queueJob, $this->connection)) { + $this->logError("Job {$job->getJobId()} finished but its lease was lost; another worker may run it again"); - // Delete the job from queue if successful - $this->manager->delete($queueJob); + return; + } + } catch (\Throwable $e) { + $this->logError("Job {$job->getJobId()} finished but could not be removed from the queue: " . $e->getMessage()); + return; + } + + try { // Dispatch next job in chain if this job is chained if ($job->isChained()) { $job->dispatchNextChainJob(); @@ -211,7 +297,7 @@ protected function processJob(QueueJob $queueJob): void ($this->onJobProcessed)($job); } } catch (\Throwable $e) { - $this->handleJobException($queueJob, $job ?? null, $e); + $this->logError("Post-processing of job {$job->getJobId()} failed: " . $e->getMessage()); } } @@ -300,6 +386,8 @@ protected function executeJobWithTimeout(JobInterface $job): void return; } + $this->renewLease(); + // Check timeout if ((time() - $startTime) >= $timeout) { $timedOut = true; @@ -338,19 +426,19 @@ protected function executeJob(JobInterface $job): void /** * Handle an exception that occurred while processing a job. * - * @param QueueJob $queueJob + * @param ReservedJob $queueJob * @param JobInterface|null $job * @param \Throwable $exception * @return void */ - protected function handleJobException(QueueJob $queueJob, ?JobInterface $job, \Throwable $exception): void + protected function handleJobException(ReservedJob $queueJob, ?JobInterface $job, \Throwable $exception): void { try { $this->logError("Job failed: " . $exception->getMessage()); if ($job === null) { // Could not unserialize job, mark as failed immediately - $this->manager->markAsFailed($queueJob, $exception); + $this->manager->markAsFailed($queueJob, $exception, $this->connection); return; } @@ -362,8 +450,8 @@ protected function handleJobException(QueueJob $queueJob, ?JobInterface $job, \T // Check if job should be retried if ($queueJob->attempts < $job->tries()) { // Release the job back to the queue with delay - $delay = $job->retryAfter(); - $released = $this->manager->release($queueJob, $delay); + $delay = $this->retryDelay($job, $queueJob->attempts); + $released = $this->manager->release($queueJob, $delay, $this->connection); if ($released) { $this->logInfo("Job {$job->getJobId()} released back to queue (attempt {$queueJob->attempts}/{$job->tries()})"); @@ -372,7 +460,7 @@ protected function handleJobException(QueueJob $queueJob, ?JobInterface $job, \T } } else { // Max attempts reached, mark as failed - $this->manager->markAsFailed($queueJob, $exception); + $this->manager->markAsFailed($queueJob, $exception, $this->connection); // Call the failed method on the job try { @@ -388,6 +476,50 @@ protected function handleJobException(QueueJob $queueJob, ?JobInterface $job, \T } } + /** + * Work out how long to wait before retrying a job. + * + * @param JobInterface $job + * @param int $attempts + * @return int Seconds + */ + protected function retryDelay(JobInterface $job, int $attempts): int + { + $backoff = method_exists($job, 'backoff') ? $job->backoff() : null; + + if (is_array($backoff) && $backoff !== []) { + $backoff = array_values($backoff); + + return max(0, (int) $backoff[min(max($attempts, 1), count($backoff)) - 1]); + } + + if (is_int($backoff)) { + return max(0, $backoff); + } + + return max(0, $job->retryAfter()); + } + + /** + * Keep the lease of the running job alive. + * + * @return void + */ + protected function renewLease(): void + { + if ($this->reservation === null || (time() - $this->leaseRenewedAt) < $this->leaseRenewEvery) { + return; + } + + $this->leaseRenewedAt = time(); + + try { + $this->manager->extendLease($this->reservation, $this->leaseRenewEvery * 3, $this->connection); + } catch (\Throwable $e) { + $this->logError("Could not renew job lease: " . $e->getMessage()); + } + } + /** * Handle an exception that occurred while the worker was running. * @@ -524,6 +656,10 @@ protected function configureOptions(array $options): void } $this->maxJobs = $options['maxJobs'] ?? null; + + if (isset($options['connection'])) { + $this->connection = (string) $options['connection']; + } } /** @@ -592,6 +728,28 @@ public function setMaxJobs(?int $maxJobs): void $this->maxJobs = $maxJobs; } + /** + * Set the queue connection to consume. + * + * @param string|null $connection + * @return void + */ + public function setConnection(?string $connection): void + { + $this->connection = $connection; + } + + /** + * Set how often, in seconds, the lease of a running timeout job is renewed. + * + * @param int $seconds + * @return void + */ + public function setLeaseRenewInterval(int $seconds): void + { + $this->leaseRenewEvery = max(1, $seconds); + } + /** * Get the number of jobs processed. * diff --git a/src/Support/Envelope.php b/src/Support/Envelope.php new file mode 100644 index 0000000..1f7f070 --- /dev/null +++ b/src/Support/Envelope.php @@ -0,0 +1,31 @@ +priority = max(QueueDriver::PRIORITY_MIN, min(QueueDriver::PRIORITY_MAX, $priority)); + } +} diff --git a/src/Support/FailedJobRecord.php b/src/Support/FailedJobRecord.php new file mode 100644 index 0000000..05aeb49 --- /dev/null +++ b/src/Support/FailedJobRecord.php @@ -0,0 +1,23 @@ +string('queue')->index(); $table->longText('payload'); $table->unsignedTinyInteger('attempts')->default(0); + $table->smallInteger('priority')->default(0); $table->unsignedInteger('reserved_at')->index()->nullable(); + $table->unsignedInteger('lease_expires_at')->nullable(); $table->unsignedInteger('available_at'); + $table->string('unique_key', 191)->nullable()->unique(); $table->unsignedInteger('created_at'); }); diff --git a/tests/Contract/QueueDriverContract.php b/tests/Contract/QueueDriverContract.php new file mode 100644 index 0000000..b29d41e --- /dev/null +++ b/tests/Contract/QueueDriverContract.php @@ -0,0 +1,649 @@ +now` instead of sleeping. + */ +abstract class QueueDriverContract extends TestCase +{ + protected const LEASE = 60; + + protected int $now = 1_800_000_000; + + protected QueueDriver $driver; + + /** + * Build a driver with a lease of LEASE seconds that reads time from $clock. + * + * @param \Closure(): int $clock + */ + abstract protected function makeDriver(\Closure $clock): QueueDriver; + + protected function setUp(): void + { + $this->now = 1_800_000_000; + $this->driver = $this->makeDriver(fn(): int => $this->now); + } + + protected function advance(int $seconds): void + { + $this->now += $seconds; + } + + protected function push( + string $id, + string $queue = 'default', + int $priority = 0, + int $delay = 0, + ?string $unique = null, + ?string $payload = null, + ): bool { + return $this->driver->push(new Envelope( + $id, + $queue, + $payload ?? "payload-{$id}", + $this->now + $delay, + $priority, + $unique, + $this->now + )); + } + + /** + * Claim every currently claimable job and return their payloads in order. + * + * @return array + */ + protected function drain(string|array $queues = 'default'): array + { + $payloads = []; + + while (($job = $this->driver->pop($queues)) !== null) { + $payloads[] = $job->payload; + } + + return $payloads; + } + + // ------------------------------------------------------------------ + // Claiming and ordering + // ------------------------------------------------------------------ + + public function testPopOnEmptyQueueReturnsNull(): void + { + $this->assertNull($this->driver->pop('default')); + } + + public function testPopReturnsThePushedJobOnItsFirstAttempt(): void + { + $this->push('a', 'emails'); + + $job = $this->driver->pop('emails'); + + $this->assertNotNull($job); + $this->assertSame('payload-a', $job->payload); + $this->assertSame('emails', $job->queue); + $this->assertSame(1, $job->attempts); + $this->assertSame($this->now, $job->reservedAt); + $this->assertSame($this->now + self::LEASE, $job->leaseExpiresAt); + } + + public function testJobsAreClaimedInArrivalOrder(): void + { + foreach (['a', 'b', 'c', 'd'] as $id) { + $this->push($id); + } + + $this->assertSame(['payload-a', 'payload-b', 'payload-c', 'payload-d'], $this->drain()); + } + + public function testHigherPriorityIsClaimedFirstAndTiesKeepArrivalOrder(): void + { + $this->push('low', priority: -5); + $this->push('normal'); + $this->push('high-1', priority: 10); + $this->push('high-2', priority: 10); + $this->push('urgent', priority: 100); + + $this->assertSame( + ['payload-urgent', 'payload-high-1', 'payload-high-2', 'payload-normal', 'payload-low'], + $this->drain() + ); + } + + public function testPrioritiesBeyondTheRangeAreClamped(): void + { + $this->push('max', priority: 100); + $this->push('beyond-max', priority: 5000); + $this->push('min', priority: -100); + $this->push('beyond-min', priority: -5000); + + // Clamped values tie with the limit itself, so arrival order decides. + $this->assertSame( + ['payload-max', 'payload-beyond-max', 'payload-min', 'payload-beyond-min'], + $this->drain() + ); + } + + public function testDelayedJobBecomesClaimableExactlyWhenAvailable(): void + { + $this->push('later', delay: 30); + + $this->assertNull($this->driver->pop('default')); + + $this->advance(29); + $this->assertNull($this->driver->pop('default')); + + $this->advance(1); + $this->assertSame('payload-later', $this->driver->pop('default')?->payload); + } + + public function testDelayedJobKeepsItsPriorityOnceAvailable(): void + { + $this->push('delayed-urgent', priority: 50, delay: 10); + $this->push('now-normal'); + + $this->advance(10); + + $this->assertSame(['payload-delayed-urgent', 'payload-now-normal'], $this->drain()); + } + + public function testPopTriesQueuesInTheOrderGiven(): void + { + $this->push('d1', 'default'); + $this->push('h1', 'high'); + $this->push('l1', 'low'); + + $this->assertSame('payload-h1', $this->driver->pop(['high', 'default', 'low'])?->payload); + $this->assertSame('payload-d1', $this->driver->pop(['high', 'default', 'low'])?->payload); + $this->assertSame('payload-l1', $this->driver->pop(['high', 'default', 'low'])?->payload); + $this->assertNull($this->driver->pop(['high', 'default', 'low'])); + } + + public function testPopAcceptsACommaSeparatedQueueList(): void + { + $this->push('d1', 'default'); + $this->push('h1', 'high'); + + $this->assertSame('payload-h1', $this->driver->pop('high, default')?->payload); + $this->assertSame('payload-d1', $this->driver->pop('high, default')?->payload); + } + + public function testPopFallsThroughEmptyQueuesToTheNext(): void + { + $this->push('l1', 'low'); + + $this->assertSame('payload-l1', $this->driver->pop(['high', 'default', 'low'])?->payload); + } + + public function testQueuesAreIsolatedFromEachOther(): void + { + $this->push('a', 'one'); + $this->push('b', 'two'); + + $this->assertSame(['payload-a'], $this->drain('one')); + $this->assertSame(['payload-b'], $this->drain('two')); + } + + public function testLeaseLengthCanBeOverriddenPerPop(): void + { + $this->push('a'); + + $job = $this->driver->pop('default', 5); + + $this->assertSame($this->now + 5, $job->leaseExpiresAt); + + $this->advance(5); + $this->assertSame(2, $this->driver->pop('default')?->attempts); + } + + // ------------------------------------------------------------------ + // Leases, reclaim and fencing + // ------------------------------------------------------------------ + + public function testReservedJobIsInvisibleUntilItsLeaseExpires(): void + { + $this->push('a'); + $this->driver->pop('default'); + + $this->assertNull($this->driver->pop('default')); + + $this->advance(self::LEASE - 1); + $this->assertNull($this->driver->pop('default')); + + $this->advance(1); + $this->assertSame('payload-a', $this->driver->pop('default')?->payload); + } + + public function testAttemptsAccumulateAcrossReclaims(): void + { + $this->push('a'); + + foreach ([1, 2, 3] as $expected) { + $job = $this->driver->pop('default'); + $this->assertSame($expected, $job->attempts); + $this->advance(self::LEASE); + } + } + + public function testStaleReservationCannotTouchAJobAnotherWorkerTookOver(): void + { + $this->push('a'); + + $first = $this->driver->pop('default'); + $this->advance(self::LEASE); + $second = $this->driver->pop('default'); + + $this->assertSame(2, $second->attempts); + + $this->assertFalse($this->driver->extend($first, 60), 'stale extend'); + $this->assertFalse($this->driver->release($first), 'stale release'); + $this->assertFalse($this->driver->fail($first, 'boom'), 'stale fail'); + $this->assertFalse($this->driver->delete($first), 'stale delete'); + + $this->assertSame(0, $this->driver->countFailed()); + $this->assertSame(1, $this->driver->stats('default')['reserved']); + $this->assertTrue($this->driver->delete($second), 'current reservation still works'); + } + + public function testReleasedReservationCannotBeUsedAgain(): void + { + $this->push('a'); + + $job = $this->driver->pop('default'); + $this->assertTrue($this->driver->release($job)); + + $this->assertFalse($this->driver->delete($job)); + $this->assertFalse($this->driver->extend($job, 30)); + $this->assertSame(1, $this->driver->size('default'), 'the released job is still queued'); + } + + public function testExtendKeepsAJobReservedPastItsOriginalLease(): void + { + $this->push('a'); + $job = $this->driver->pop('default'); + + $this->advance(50); + $this->assertTrue($this->driver->extend($job, 60)); + + $this->advance(30); // 80s in: past the original lease, inside the extended one + $this->assertNull($this->driver->pop('default')); + + $this->advance(30); // 110s in: extended lease (50 + 60) has run out + $this->assertSame(2, $this->driver->pop('default')?->attempts); + } + + // ------------------------------------------------------------------ + // Delete and release + // ------------------------------------------------------------------ + + public function testDeleteRemovesTheJob(): void + { + $this->push('a'); + $job = $this->driver->pop('default'); + + $this->assertTrue($this->driver->delete($job)); + $this->assertFalse($this->driver->delete($job), 'a second delete has nothing to remove'); + + $this->advance(self::LEASE * 2); + $this->assertNull($this->driver->pop('default')); + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver->stats('default')); + } + + public function testReleaseMakesTheJobClaimableAgainWithItsAttemptsKept(): void + { + $this->push('a'); + $this->assertTrue($this->driver->release($this->driver->pop('default'))); + + $again = $this->driver->pop('default'); + + $this->assertSame('payload-a', $again->payload); + $this->assertSame(2, $again->attempts); + } + + public function testReleaseWithDelayHoldsTheJobBack(): void + { + $this->push('a'); + $this->driver->release($this->driver->pop('default'), 20); + + $this->assertNull($this->driver->pop('default')); + $this->assertSame(1, $this->driver->stats('default')['delayed']); + + $this->advance(20); + $this->assertSame('payload-a', $this->driver->pop('default')?->payload); + } + + public function testReleasedJobKeepsItsPlaceInLine(): void + { + $this->push('first'); + $this->push('second'); + + $this->driver->release($this->driver->pop('default')); + + $this->assertSame(['payload-first', 'payload-second'], $this->drain()); + } + + // ------------------------------------------------------------------ + // Failed jobs + // ------------------------------------------------------------------ + + public function testFailMovesTheJobToTheFailedStore(): void + { + $this->push('a', 'emails'); + $job = $this->driver->pop('emails'); + + $this->assertTrue($this->driver->fail($job, 'RuntimeException: boom')); + + $this->assertSame(1, $this->driver->countFailed()); + $this->assertSame(0, $this->driver->size('emails')); + $this->assertSame(0, $this->driver->stats('emails')['reserved']); + + $record = $this->driver->failedJobs()[0]; + $this->assertSame('emails', $record->queue); + $this->assertSame('payload-a', $record->payload); + $this->assertSame('RuntimeException: boom', $record->exception); + $this->assertSame($this->now, $record->failedAt); + $this->assertNotSame('', $record->connection); + } + + public function testFailedJobsAreListedNewestFirst(): void + { + foreach (['a', 'b', 'c'] as $id) { + $this->push($id); + $this->driver->fail($this->driver->pop('default'), "failure {$id}"); + $this->advance(10); + } + + $this->assertSame( + ['failure c', 'failure b', 'failure a'], + array_map(fn($record) => $record->exception, $this->driver->failedJobs()) + ); + } + + public function testFailedJobsCanBeFoundForgottenAndFlushed(): void + { + foreach (['a', 'b', 'c'] as $id) { + $this->push($id); + $this->driver->fail($this->driver->pop('default'), "failure {$id}"); + } + + $records = $this->driver->failedJobs(); + $target = $records[1]; + + $this->assertSame($target->payload, $this->driver->findFailed($target->id)?->payload); + $this->assertTrue($this->driver->forgetFailed($target->id)); + $this->assertNull($this->driver->findFailed($target->id)); + $this->assertFalse($this->driver->forgetFailed($target->id)); + $this->assertSame(2, $this->driver->countFailed()); + + $this->assertSame(2, $this->driver->flushFailed()); + $this->assertSame(0, $this->driver->countFailed()); + $this->assertSame([], $this->driver->failedJobs()); + } + + public function testUnknownFailedJobIsNotFound(): void + { + $this->assertNull($this->driver->findFailed(999999)); + $this->assertFalse($this->driver->forgetFailed(999999)); + } + + // ------------------------------------------------------------------ + // Uniqueness + // ------------------------------------------------------------------ + + public function testUniqueKeyRefusesADuplicateWhileTheFirstIsQueued(): void + { + $this->assertTrue($this->push('a', unique: 'report:7')); + $this->assertFalse($this->push('b', unique: 'report:7')); + + $this->assertSame(['payload-a'], $this->drain()); + } + + public function testDifferentUniqueKeysAndMissingKeysNeverConflict(): void + { + $this->assertTrue($this->push('a', unique: 'report:1')); + $this->assertTrue($this->push('b', unique: 'report:2')); + $this->assertTrue($this->push('c')); + $this->assertTrue($this->push('d')); + + $this->assertSame(4, $this->driver->size('default')); + } + + public function testUniqueKeyIsHeldWhileTheJobIsReservedAndAfterRelease(): void + { + $this->push('a', unique: 'k'); + $job = $this->driver->pop('default'); + + $this->assertFalse($this->push('b', unique: 'k'), 'held while reserved'); + + $this->driver->release($job); + $this->assertFalse($this->push('c', unique: 'k'), 'held after release'); + } + + public function testUniqueKeyIsFreedWhenTheJobIsDeleted(): void + { + $this->push('a', unique: 'k'); + $this->driver->delete($this->driver->pop('default')); + + $this->assertTrue($this->push('b', unique: 'k')); + } + + public function testUniqueKeyIsFreedWhenTheJobFails(): void + { + $this->push('a', unique: 'k'); + $this->driver->fail($this->driver->pop('default'), 'boom'); + + $this->assertTrue($this->push('b', unique: 'k')); + } + + public function testUniqueKeyIsFreedWhenTheQueueIsCleared(): void + { + $this->push('a', unique: 'k'); + $this->driver->clear('default'); + + $this->assertTrue($this->push('b', unique: 'k')); + } + + public function testStaleReservationDoesNotFreeAUniqueKeyItNoLongerOwns(): void + { + $this->push('a', unique: 'k'); + $first = $this->driver->pop('default'); + $this->advance(self::LEASE); + $this->driver->pop('default'); + + $this->assertFalse($this->driver->delete($first)); + $this->assertFalse($this->push('b', unique: 'k'), 'the live job still owns the key'); + } + + // ------------------------------------------------------------------ + // Bulk push, sizes and housekeeping + // ------------------------------------------------------------------ + + public function testPushManyStoresJobsInOrder(): void + { + $stored = $this->driver->pushMany([ + new Envelope('a', 'default', 'payload-a', $this->now, 0, null, $this->now), + new Envelope('b', 'default', 'payload-b', $this->now, 0, null, $this->now), + new Envelope('c', 'other', 'payload-c', $this->now, 0, null, $this->now), + ]); + + $this->assertSame(3, $stored); + $this->assertSame(['payload-a', 'payload-b'], $this->drain('default')); + $this->assertSame(['payload-c'], $this->drain('other')); + } + + public function testPushManySkipsDuplicatesOfUniqueJobsAndKeepsTheRestInOrder(): void + { + $this->push('existing', unique: 'k'); + + $stored = $this->driver->pushMany([ + new Envelope('a', 'default', 'payload-a', $this->now, 0, null, $this->now), + new Envelope('dup', 'default', 'payload-dup', $this->now, 0, 'k', $this->now), + new Envelope('b', 'default', 'payload-b', $this->now, 0, null, $this->now), + new Envelope('fresh', 'default', 'payload-fresh', $this->now, 0, 'other-key', $this->now), + new Envelope('dup-in-batch', 'default', 'payload-dup2', $this->now, 0, 'other-key', $this->now), + ]); + + $this->assertSame(3, $stored); + $this->assertSame( + ['payload-existing', 'payload-a', 'payload-b', 'payload-fresh'], + $this->drain() + ); + } + + public function testPushManyHandlesLargeBatches(): void + { + $envelopes = []; + + for ($i = 0; $i < 450; $i++) { + $envelopes[] = new Envelope("job-{$i}", 'bulk', "payload-{$i}", $this->now, 0, null, $this->now); + } + + $this->assertSame(450, $this->driver->pushMany($envelopes)); + $this->assertSame(450, $this->driver->size('bulk')); + + $drained = $this->drain('bulk'); + + $this->assertCount(450, $drained); + $this->assertSame('payload-0', $drained[0]); + $this->assertSame('payload-449', $drained[449]); + } + + public function testPushManyWithNoJobsIsANoOp(): void + { + $this->assertSame(0, $this->driver->pushMany([])); + } + + public function testStatsSeparateReadyDelayedAndReservedJobs(): void + { + $this->push('ready-1'); + $this->push('ready-2'); + $this->push('delayed', delay: 100); + $this->push('will-be-reserved'); + $this->driver->pop('default'); + + $this->assertSame(['ready' => 2, 'delayed' => 1, 'reserved' => 1], $this->driver->stats('default')); + } + + public function testSizeCountsReadyAndDelayedButNotReserved(): void + { + $this->push('a'); + $this->push('b', delay: 100); + $this->push('c'); + $this->driver->pop('default'); + + $this->assertSame(2, $this->driver->size('default')); + } + + public function testJobWithExpiredLeaseCountsAsReady(): void + { + $this->push('a'); + $this->driver->pop('default'); + + $this->advance(self::LEASE); + + $this->assertSame(['ready' => 1, 'delayed' => 0, 'reserved' => 0], $this->driver->stats('default')); + } + + public function testStatsForAnUnknownQueueAreZero(): void + { + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver->stats('nope')); + $this->assertSame(0, $this->driver->size('nope')); + } + + public function testQueuesListsOnlyQueuesThatHoldJobs(): void + { + $this->push('a', 'emails'); + $this->push('b', 'reports'); + $this->push('c', 'gone'); + $this->driver->clear('gone'); + + $this->assertSame(['emails', 'reports'], $this->driver->queues()); + } + + public function testClearRemovesReadyDelayedAndReservedJobsOfOneQueue(): void + { + $this->push('ready', 'target'); + $this->push('delayed', 'target', delay: 100); + $this->push('reserved', 'target'); + $this->driver->pop('target'); + $this->push('bystander', 'other'); + + $this->assertSame(3, $this->driver->clear('target')); + + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver->stats('target')); + $this->assertSame(['payload-bystander'], $this->drain('other')); + } + + public function testClearOnAnEmptyQueueReturnsZero(): void + { + $this->assertSame(0, $this->driver->clear('nothing')); + } + + public function testClearHandlesMoreJobsThanOneBatch(): void + { + $envelopes = []; + + for ($i = 0; $i < 1500; $i++) { + $envelopes[] = new Envelope("job-{$i}", 'big', 'x', $this->now, 0, null, $this->now); + } + + $this->driver->pushMany($envelopes); + + $this->assertSame(1500, $this->driver->clear('big')); + $this->assertSame(0, $this->driver->size('big')); + } + + // ------------------------------------------------------------------ + // Payload fidelity + // ------------------------------------------------------------------ + + /** + * @return array + */ + public static function payloads(): array + { + return [ + 'ascii' => ['plain text'], + 'unicode' => ['ব্যবহারকারী — naïve café 日本語 🚀'], + 'null bytes and binary' => ["a\0b\xFF\xFEc\x01\x02"], + 'null byte in valid utf-8' => ["abc\0def \u{1F680}"], + 'serialized php' => [serialize(['job' => new \ArrayObject([1, 2, 3]), 'n' => 1.5])], + 'quotes and sql' => ["'; DROP TABLE queue_jobs; -- \" \\ %s ?"], + 'large (200 KB)' => [str_repeat('0123456789abcdef', 12_800)], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('payloads')] + public function testPayloadsSurviveARoundTripByteForByte(string $payload): void + { + $this->push('a', payload: $payload); + + $this->assertSame($payload, $this->driver->pop('default')?->payload); + } + + public function testFailedPayloadAndExceptionSurviveByteForByte(): void + { + $payload = "bin\0ary ব্যবহারকারী"; + // Diagnostic text: databases may not store NUL or invalid UTF-8 here, so + // the contract only covers text every backend can hold. + $exception = "Error: \"quoted\" ব্যবহারকারী 🚀 trace\nline 2 'x' % ?"; + + $this->push('a', payload: $payload); + $this->driver->fail($this->driver->pop('default'), $exception); + + $record = $this->driver->failedJobs()[0]; + + $this->assertSame($payload, $record->payload); + $this->assertSame($exception, $record->exception); + } +} diff --git a/tests/Drivers/ConcurrentClaimTest.php b/tests/Drivers/ConcurrentClaimTest.php new file mode 100644 index 0000000..36f4f51 --- /dev/null +++ b/tests/Drivers/ConcurrentClaimTest.php @@ -0,0 +1,286 @@ + + */ + public static function backends(): array + { + return ['sqlite file' => ['sqlite'], 'redis' => ['redis'], 'mysql' => ['mysql'], 'pgsql' => ['pgsql']]; + } + + protected function setUp(): void + { + if (!function_exists('proc_open') || !function_exists('posix_kill')) { + $this->backendUnavailable('proc_open and posix are required.'); + } + + $this->sqliteFile = sys_get_temp_dir() . '/dq_concurrency_' . bin2hex(random_bytes(6)) . '.sqlite'; + $this->redisPrefix = '{dqconc_' . bin2hex(random_bytes(6)) . '}'; + } + + protected function tearDown(): void + { + foreach ([$this->sqliteFile, $this->sqliteFile . '-wal', $this->sqliteFile . '-shm'] as $file) { + @unlink($file); + } + + (new \ReflectionProperty(Database::class, 'connections'))->setValue(null, []); + + $this->mysqlPdo?->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs'); + $this->pgsqlPdo?->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs'); + + if (isset($this->redisPrefix)) { + try { + $client = RedisDriverTest::redisClient(); + $cursor = '0'; + + do { + [$cursor, $keys] = $client->scan($cursor, ['MATCH' => $this->redisPrefix . ':*', 'COUNT' => 500]); + + foreach (array_chunk($keys, 200) as $chunk) { + $client->del($chunk); + } + } while ($cursor !== '0'); + } catch (\Throwable) { + // Redis was not in use. + } + } + } + + private function driver(string $backend, int $lease): QueueDriver + { + if ($backend === 'pgsql') { + $dsn = getenv('QUEUE_TEST_PGSQL_DSN'); + $schema = (string) getenv('QUEUE_TEST_PGSQL_SCHEMA'); + + if (!$dsn || !preg_match('/^[a-z0-9_]*(test|scratch)[a-z0-9_]*$/i', $schema)) { + $this->backendUnavailable('Set QUEUE_TEST_PGSQL_DSN and a QUEUE_TEST_PGSQL_SCHEMA containing "test" or "scratch".'); + } + + $pdo = new PDO($dsn, getenv('QUEUE_TEST_PGSQL_USER') ?: null, getenv('QUEUE_TEST_PGSQL_PASS') ?: null); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + // The name was checked above, so it is safe to create it when missing. + $pdo->exec("CREATE SCHEMA IF NOT EXISTS {$schema}"); + $pdo->exec("SET search_path TO {$schema}"); + + if (trim((string) $pdo->query('SHOW search_path')->fetchColumn(), '" ') !== $schema) { + $this->backendUnavailable('Could not pin search_path to the scratch schema.'); + } + + $pdo->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs'); + QueueSchema::createPgsql($pdo); + $this->pgsqlPdo = $pdo; + (new \ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + return new DatabaseDriver(['connection' => 'contract', 'lease' => $lease]); + } + + if ($backend === 'mysql') { + $dsn = getenv('QUEUE_TEST_MYSQL_DSN'); + + if (!$dsn) { + $this->backendUnavailable('Set QUEUE_TEST_MYSQL_DSN to run the MySQL concurrency tests.'); + } + + $pdo = new PDO($dsn, getenv('QUEUE_TEST_MYSQL_USER') ?: null, getenv('QUEUE_TEST_MYSQL_PASS') ?: null); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + if (!preg_match('/test|scratch/i', (string) $pdo->query('SELECT DATABASE()')->fetchColumn())) { + $this->backendUnavailable('Refusing to drop tables: the database name must contain "test" or "scratch".'); + } + + $pdo->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs'); + QueueSchema::createMysql($pdo); + $this->mysqlPdo = $pdo; + (new \ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + return new DatabaseDriver(['connection' => 'contract', 'lease' => $lease]); + } + + if ($backend === 'sqlite') { + $pdo = new PDO('sqlite:' . $this->sqliteFile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('PRAGMA journal_mode = WAL'); + $pdo->exec('PRAGMA busy_timeout = 30000'); + QueueSchema::create($pdo); + (new \ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + return new DatabaseDriver(['connection' => 'contract', 'lease' => $lease]); + } + + $client = RedisDriverTest::redisClient(); + + try { + $client->ping(); + } catch (\Throwable $e) { + $this->backendUnavailable('Redis is not reachable: ' . $e->getMessage()); + } + + return new RedisDriver(['prefix' => $this->redisPrefix, 'lease' => $lease], $client); + } + + /** + * @return array + */ + private function spec(string $backend, int $lease, string $mode, float $startAt): array + { + return [ + 'backend' => $backend, + 'file' => $this->sqliteFile, + 'prefix' => $this->redisPrefix, + 'url' => getenv('QUEUE_TEST_REDIS_URL') ?: 'redis://127.0.0.1:6379', + 'db' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15), + 'pg_dsn' => getenv('QUEUE_TEST_PGSQL_DSN') ?: '', + 'pg_user' => getenv('QUEUE_TEST_PGSQL_USER') ?: null, + 'pg_pass' => getenv('QUEUE_TEST_PGSQL_PASS') ?: null, + 'pg_schema' => (string) getenv('QUEUE_TEST_PGSQL_SCHEMA'), + 'dsn' => getenv('QUEUE_TEST_MYSQL_DSN') ?: '', + 'user' => getenv('QUEUE_TEST_MYSQL_USER') ?: null, + 'pass' => getenv('QUEUE_TEST_MYSQL_PASS') ?: null, + 'lease' => $lease, + 'mode' => $mode, + 'start_at' => $startAt, + ]; + } + + /** + * Start a claiming worker process. + * + * @return array{resource, array} + */ + private function spawn(array $spec): array + { + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../Support/claim_worker.php', base64_encode(json_encode($spec))], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + + $this->assertIsResource($process, 'could not start a worker process'); + + return [$process, $pipes]; + } + + /** + * @return array{array, string, int} claimed payloads, stderr, exit code + */ + private function collect(array $worker): array + { + [$process, $pipes] = $worker; + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + + $lines = array_values(array_filter(explode("\n", $stdout), fn($line) => $line !== '')); + + return [$lines, $stderr, $exit]; + } + + #[DataProvider('backends')] + public function testConcurrentWorkersClaimEveryJobExactlyOnce(string $backend): void + { + $driver = $this->driver($backend, 300); + $now = time(); + + $envelopes = []; + + for ($i = 0; $i < self::JOBS; $i++) { + $envelopes[] = new Envelope("job-{$i}", 'default', "job-{$i}", $now, 0, null, $now); + } + + $driver->pushMany($envelopes); + + $startAt = microtime(true) + 1.0; + $workers = []; + + for ($w = 0; $w < self::WORKERS; $w++) { + $workers[] = $this->spawn($this->spec($backend, 300, 'drain', $startAt)); + } + + $claimedBy = []; + $all = []; + + foreach ($workers as $index => $worker) { + [$claimed, $stderr, $exit] = $this->collect($worker); + + $this->assertSame('', $stderr, "worker {$index} wrote to stderr"); + $this->assertSame(0, $exit, "worker {$index} exited abnormally"); + + $claimedBy[$index] = count($claimed); + $all = array_merge($all, $claimed); + } + + $this->assertCount(self::JOBS, $all, 'every job must be claimed'); + $this->assertSame([], array_diff_key($all, array_unique($all)), 'no job may be claimed by two workers'); + $this->assertCount(self::JOBS, array_unique($all)); + + $this->assertGreaterThan( + 1, + count(array_filter($claimedBy)), + 'the test is only meaningful if several workers took part: ' . json_encode($claimedBy) + ); + + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $driver->stats('default')); + } + + #[DataProvider('backends')] + public function testJobOfAKilledWorkerIsRecoveredAfterItsLeaseExpires(string $backend): void + { + $driver = $this->driver($backend, 2); + $now = time(); + + $driver->push(new Envelope('only', 'default', 'only-job', $now, 0, null, $now)); + + $worker = $this->spawn($this->spec($backend, 2, 'crash', microtime(true))); + [$claimed] = $this->collect($worker); + + $this->assertSame(['only-job'], $claimed, 'the doomed worker claimed the job'); + $this->assertNull($driver->pop('default'), 'the dead worker still holds the lease'); + + sleep(3); + + $recovered = $driver->pop('default'); + + $this->assertNotNull($recovered, 'the job must come back once the lease has expired'); + $this->assertSame('only-job', $recovered->payload); + $this->assertSame(2, $recovered->attempts); + } +} diff --git a/tests/Drivers/DatabaseDriverTest.php b/tests/Drivers/DatabaseDriverTest.php new file mode 100644 index 0000000..feb891d --- /dev/null +++ b/tests/Drivers/DatabaseDriverTest.php @@ -0,0 +1,250 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + QueueSchema::create($pdo); + + return $pdo; + } + + protected function isSqlite(): bool + { + return true; + } + + protected function makeDriver(\Closure $clock): QueueDriver + { + $this->pdo = $this->openDatabase(); + + $this->setConnections(['contract' => $this->pdo]); + + return new DatabaseDriver(['connection' => 'contract', 'lease' => self::LEASE], $clock); + } + + protected function tearDown(): void + { + $this->setConnections([]); + } + + protected function setConnections(array $connections): void + { + (new \ReflectionProperty(Database::class, 'connections'))->setValue(null, $connections); + } + + public function testClaimingIsACompareAndSwapOnTheAttemptsCounter(): void + { + $this->push('a'); + + // Another worker claims the row between our SELECT and UPDATE: simulate + // it by bumping attempts and reserving the row before we pop. + $this->pdo->exec( + "UPDATE queue_jobs SET attempts = 1, reserved_at = {$this->now}, lease_expires_at = " . ($this->now + 60) + ); + + $this->assertNull($this->driver->pop('default'), 'a row claimed elsewhere must not be claimed twice'); + } + + public function testRowsWrittenBeforeLeasesExistExpireAfterTheDefaultLease(): void + { + // A row reserved by the previous version of the package: reserved_at + // set, no lease_expires_at column value. + $this->pdo->exec( + "INSERT INTO queue_jobs (queue, payload, attempts, reserved_at, lease_expires_at, available_at, created_at) + VALUES ('default', 'legacy', 1, {$this->now}, NULL, {$this->now}, {$this->now})" + ); + + $this->assertNull($this->driver->pop('default')); + + $this->advance(self::LEASE); + + $job = $this->driver->pop('default'); + $this->assertSame('legacy', $job?->payload); + $this->assertSame(2, $job->attempts); + } + + public function testFailIsAtomicWhenTheFailedStoreIsUnavailable(): void + { + $this->push('a'); + $job = $this->driver->pop('default'); + + $this->pdo->exec('DROP TABLE failed_jobs'); + + try { + $this->driver->fail($job, 'boom'); + $this->fail('fail() should have thrown'); + } catch (\PDOException) { + // expected + } + + $stats = $this->driver->stats('default'); + $this->assertSame(1, $stats['reserved'], 'the job must not be lost when it could not be recorded as failed'); + } + + public function testFailJoinsAnOutsideTransactionInsteadOfCommittingIt(): void + { + $this->push('a'); + $job = $this->driver->pop('default'); + + $this->pdo->beginTransaction(); + $this->driver->fail($job, 'boom'); + $this->pdo->rollBack(); + + $this->assertSame(0, $this->driver->countFailed()); + $this->assertSame(1, $this->driver->stats('default')['reserved']); + } + + public function testDuplicateUniqueKeyDoesNotLeakTheConstraintError(): void + { + $this->assertTrue($this->push('a', unique: 'k')); + $this->assertFalse($this->push('b', unique: 'k')); + $this->assertSame(1, $this->driver->size('default')); + } + + public function testConstraintViolationsWithoutAUniqueKeyStillSurface(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('Uses SQLite index syntax.'); + } + + $this->pdo->exec('DROP INDEX idx_queue_unique_key'); + $this->pdo->exec('CREATE UNIQUE INDEX idx_queue_payload ON queue_jobs(payload)'); + + $this->push('a', payload: 'same'); + + $this->expectException(\PDOException::class); + $this->push('b', payload: 'same'); + } + + public function testOrdinaryPayloadsAreStoredAsPlainReadableText(): void + { + $this->push('a', payload: 'a:1:{s:3:"job";s:2:"ok";}'); + + $this->assertSame( + 'a:1:{s:3:"job";s:2:"ok";}', + $this->pdo->query('SELECT payload FROM queue_jobs')->fetchColumn() + ); + } + + public function testBinaryPayloadsAreStoredEncodedAndReturnedIntact(): void + { + $binary = "raw \xFF\xFE bytes \0 and more"; + + $this->push('a', payload: $binary); + + $stored = $this->pdo->query('SELECT payload FROM queue_jobs')->fetchColumn(); + + $this->assertStringStartsWith('base64:', $stored); + $this->assertTrue(mb_check_encoding($stored, 'UTF-8'), 'the stored value must fit a text column'); + $this->assertSame($binary, $this->driver->pop('default')?->payload); + } + + public function testAPayloadThatLooksLikeTheEncodingMarkerIsNotMisread(): void + { + $lookalike = 'base64:' . base64_encode('something else'); + + $this->push('a', payload: $lookalike); + + $this->assertSame($lookalike, $this->driver->pop('default')?->payload); + } + + public function testRowsWithPlainPayloadsWrittenByOlderVersionsStillRead(): void + { + $this->pdo->exec( + "INSERT INTO queue_jobs (queue, payload, attempts, available_at, created_at) + VALUES ('default', 'old plain payload', 0, {$this->now}, {$this->now})" + ); + + $this->assertSame('old plain payload', $this->driver->pop('default')?->payload); + } + + public function testFailedBinaryPayloadsAreEncodedAndReadBackIntact(): void + { + $binary = "bin \xFF\xFE \0"; + + $this->push('a', payload: $binary); + $this->driver->fail($this->driver->pop('default'), 'boom'); + + $this->assertSame($binary, $this->driver->failedJobs()[0]->payload); + $this->assertSame($binary, $this->driver->findFailed($this->driver->failedJobs()[0]->id)?->payload); + } + + public function testBinaryPayloadsInABulkPushSurvive(): void + { + $binary = "bulk \xFF\xFE \0"; + $envelopes = [ + new Envelope('a', 'default', $binary, $this->now, 0, null, $this->now), + new Envelope('b', 'default', 'plain', $this->now, 0, null, $this->now), + ]; + + $this->driver->pushMany($envelopes); + + $this->assertSame($binary, $this->driver->pop('default')?->payload); + $this->assertSame('plain', $this->driver->pop('default')?->payload); + } + + public function testAnExceptionMessageWithUnstorableBytesStillFailsTheJob(): void + { + $this->push('a'); + $job = $this->driver->pop('default'); + + $this->assertTrue($this->driver->fail($job, "bad \0 byte and \xFF\xFE invalid utf-8")); + + $this->assertSame('bad \\0 byte and ?? invalid utf-8', $this->driver->failedJobs()[0]->exception); + $this->assertSame(0, $this->driver->stats('default')['reserved'], 'the job must not be left stuck'); + } + + public function testCustomTableNamesAreHonoured(): void + { + $this->pdo->exec('ALTER TABLE queue_jobs RENAME TO my_jobs'); + $this->pdo->exec('ALTER TABLE failed_jobs RENAME TO my_failed'); + + $driver = new DatabaseDriver([ + 'connection' => 'contract', + 'table' => 'my_jobs', + 'failed_table' => 'my_failed', + 'lease' => self::LEASE, + ], fn(): int => $this->now); + + $driver->push(new Envelope('a', 'default', 'payload-a', $this->now, 0, null, $this->now)); + $driver->fail($driver->pop('default'), 'boom'); + + $this->assertSame(1, $driver->countFailed()); + } + + public function testConnectionsAreResolvedOnEveryCallSoReopenedConnectionsAreUsed(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('Needs a second empty in-memory database.'); + } + + $this->push('a'); + + // The worker drops and reopens connections around forks. Swap the + // underlying handle for a new one holding the same data. + $fresh = new PDO('sqlite::memory:'); + $fresh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + QueueSchema::create($fresh); + $this->setConnections(['contract' => $fresh]); + + $this->assertSame(0, $this->driver->size('default'), 'the driver must not cache the old handle'); + } +} diff --git a/tests/Drivers/LeaseRenewalTest.php b/tests/Drivers/LeaseRenewalTest.php new file mode 100644 index 0000000..86b3add --- /dev/null +++ b/tests/Drivers/LeaseRenewalTest.php @@ -0,0 +1,151 @@ +backendUnavailable('pcntl and proc_open are required.'); + } + + $this->client = RedisDriverTest::redisClient(); + + try { + $this->client->ping(); + } catch (\Throwable $e) { + $this->backendUnavailable('Redis is not reachable: ' . $e->getMessage()); + } + + $this->prefix = '{dqlease_' . bin2hex(random_bytes(6)) . '}'; + } + + protected function tearDown(): void + { + if (!isset($this->client)) { + return; + } + + $cursor = '0'; + + do { + [$cursor, $keys] = $this->client->scan($cursor, ['MATCH' => $this->prefix . ':*', 'COUNT' => 500]); + + foreach (array_chunk($keys, 200) as $chunk) { + $this->client->del($chunk); + } + } while ($cursor !== '0'); + } + + private function driver(): RedisDriver + { + return new RedisDriver(['prefix' => $this->prefix, 'lease' => self::LEASE], $this->client); + } + + /** + * Run the slow job in a worker process while this process tries to claim it. + * + * @return array{claimedByRival: bool, workerOutput: string, exit: int} + */ + private function raceAgainstSlowJob(int $renewEvery): array + { + $manager = new QueueManager(['default' => 'redis', 'connections' => ['redis' => [ + 'driver' => 'redis', + 'prefix' => $this->prefix, + 'lease' => self::LEASE, + 'options' => ['parameters' => ['database' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15)]], + ]]]); + $manager->extend('redis', fn() => $this->driver()); + $manager->push(new SleepJob(self::JOB_SECONDS)); + + $spec = base64_encode(json_encode([ + 'url' => getenv('QUEUE_TEST_REDIS_URL') ?: 'redis://127.0.0.1:6379', + 'db' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15), + 'prefix' => $this->prefix, + 'lease' => self::LEASE, + 'renew_every' => $renewEvery, + ])); + + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../Support/slow_job_worker.php', $spec], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + + stream_set_blocking($pipes[1], false); + + $claimedByRival = false; + $deadline = microtime(true) + self::JOB_SECONDS + 3; + + // Give the worker time to claim the job, then keep trying to take it. + usleep(800_000); + + while (microtime(true) < $deadline) { + $status = proc_get_status($process); + + if (!$status['running']) { + break; + } + + if ($this->driver()->pop('default') !== null) { + $claimedByRival = true; + break; + } + + usleep(250_000); + } + + stream_set_blocking($pipes[1], true); + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + + return ['claimedByRival' => $claimedByRival, 'workerOutput' => $output, 'exit' => $exit]; + } + + public function testALongRunningJobKeepsItsLeaseAndIsNotStolen(): void + { + $result = $this->raceAgainstSlowJob(renewEvery: 1); + + $this->assertFalse($result['claimedByRival'], 'a second worker took a job that is still running'); + $this->assertStringContainsString('processed', $result['workerOutput']); + $this->assertStringNotContainsString('lease was lost', $result['workerOutput']); + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver()->stats('default')); + $this->assertSame(0, $this->driver()->countFailed()); + } + + public function testWithoutRenewalTheSameJobIsStolenWhichProvesTheTestCanFail(): void + { + $result = $this->raceAgainstSlowJob(renewEvery: 3600); + + $this->assertTrue($result['claimedByRival'], 'without renewal the lease must run out mid-job'); + $this->assertStringContainsString('lease was lost', $result['workerOutput']); + } +} diff --git a/tests/Drivers/MemoryDriverTest.php b/tests/Drivers/MemoryDriverTest.php new file mode 100644 index 0000000..13e671a --- /dev/null +++ b/tests/Drivers/MemoryDriverTest.php @@ -0,0 +1,24 @@ + self::LEASE], $clock); + } + + public function testEachDriverInstanceHasItsOwnJobs(): void + { + $this->push('a'); + + $other = new MemoryDriver(['lease' => self::LEASE], fn(): int => $this->now); + + $this->assertNull($other->pop('default')); + } +} diff --git a/tests/Drivers/MysqlDatabaseDriverTest.php b/tests/Drivers/MysqlDatabaseDriverTest.php new file mode 100644 index 0000000..0771e2b --- /dev/null +++ b/tests/Drivers/MysqlDatabaseDriverTest.php @@ -0,0 +1,70 @@ +backendUnavailable('Set QUEUE_TEST_MYSQL_DSN to run the MySQL driver tests.'); + } + + try { + $pdo = new PDO($dsn, getenv('QUEUE_TEST_MYSQL_USER') ?: null, getenv('QUEUE_TEST_MYSQL_PASS') ?: null); + } catch (\PDOException $e) { + $this->backendUnavailable('MySQL is not reachable: ' . $e->getMessage()); + } + + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $database = (string) $pdo->query('SELECT DATABASE()')->fetchColumn(); + + if (!preg_match('/test|scratch/i', $database)) { + $this->backendUnavailable("Refusing to drop tables in [{$database}]: its name must contain \"test\" or \"scratch\"."); + } + + $this->dropTables($pdo); + QueueSchema::createMysql($pdo); + + return $pdo; + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->dropTables($this->pdo); + } + + parent::tearDown(); + } + + private function dropTables(PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs, my_jobs, my_failed'); + } +} diff --git a/tests/Drivers/PgsqlDatabaseDriverTest.php b/tests/Drivers/PgsqlDatabaseDriverTest.php new file mode 100644 index 0000000..567a036 --- /dev/null +++ b/tests/Drivers/PgsqlDatabaseDriverTest.php @@ -0,0 +1,79 @@ +backendUnavailable('Set QUEUE_TEST_PGSQL_DSN and QUEUE_TEST_PGSQL_SCHEMA to run the PostgreSQL driver tests.'); + } + + if (!preg_match('/^[a-z0-9_]*(test|scratch)[a-z0-9_]*$/i', $schema)) { + $this->backendUnavailable("Refusing schema [{$schema}]: its name must contain \"test\" or \"scratch\"."); + } + + try { + $pdo = new PDO($dsn, getenv('QUEUE_TEST_PGSQL_USER') ?: null, getenv('QUEUE_TEST_PGSQL_PASS') ?: null); + } catch (\PDOException $e) { + $this->backendUnavailable('PostgreSQL is not reachable: ' . $e->getMessage()); + } + + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + // The name was checked above, so it is safe to create it when missing. + $pdo->exec("CREATE SCHEMA IF NOT EXISTS {$schema}"); + $pdo->exec("SET search_path TO {$schema}"); + + $path = trim((string) $pdo->query('SHOW search_path')->fetchColumn(), '" '); + + if ($path !== $schema || $pdo->query('SELECT current_schema()')->fetchColumn() !== $schema) { + $this->backendUnavailable("Could not pin search_path to [{$schema}] (got [{$path}])."); + } + + $this->dropTables($pdo); + QueueSchema::createPgsql($pdo); + + return $pdo; + } + + protected function tearDown(): void + { + if (isset($this->pdo)) { + $this->dropTables($this->pdo); + } + + parent::tearDown(); + } + + private function dropTables(PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS queue_jobs, failed_jobs, my_jobs, my_failed'); + } +} diff --git a/tests/Drivers/RedisDriverTest.php b/tests/Drivers/RedisDriverTest.php new file mode 100644 index 0000000..7088bdf --- /dev/null +++ b/tests/Drivers/RedisDriverTest.php @@ -0,0 +1,195 @@ +client = self::redisClient(); + + try { + $this->client->ping(); + } catch (\Throwable $e) { + $this->backendUnavailable('Redis is not reachable: ' . $e->getMessage()); + } + + $this->prefix = '{dqtest_' . bin2hex(random_bytes(6)) . '}'; + + return new RedisDriver(['prefix' => $this->prefix, 'lease' => self::LEASE], $this->client, $clock); + } + + protected function tearDown(): void + { + if (isset($this->client, $this->prefix)) { + $this->deleteKeys($this->allKeys()); + } + } + + public static function redisClient(): Client + { + return new Client( + getenv('QUEUE_TEST_REDIS_URL') ?: 'redis://127.0.0.1:6379', + ['parameters' => ['database' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15)]] + ); + } + + /** + * @return array + */ + private function allKeys(): array + { + $keys = []; + $cursor = '0'; + + do { + [$cursor, $batch] = $this->client->scan($cursor, ['MATCH' => $this->prefix . ':*', 'COUNT' => 500]); + $keys = array_merge($keys, $batch); + } while ($cursor !== '0'); + + return array_values(array_unique($keys)); + } + + /** + * @param array $keys + */ + private function deleteKeys(array $keys): void + { + foreach (array_chunk($keys, 200) as $chunk) { + $this->client->del($chunk); + } + } + + public function testEveryKeyLivesUnderTheConfiguredPrefixWithAHashTag(): void + { + $this->push('a', unique: 'k'); + $this->push('b', delay: 30); + $this->driver->fail($this->driver->pop('default'), 'boom'); + + $keys = $this->allKeys(); + + $this->assertNotEmpty($keys); + $this->assertMatchesRegularExpression('/^\{dqtest_[0-9a-f]+\}$/', $this->prefix); + + // Redis Cluster puts keys on the slot of the text inside the first braces. + foreach ($keys as $key) { + $this->assertStringStartsWith($this->prefix . ':', $key); + } + } + + public function testFinishedJobsLeaveNoKeysBehind(): void + { + $this->push('a', unique: 'k'); + $this->driver->delete($this->driver->pop('default')); + + $leftovers = array_filter( + $this->allKeys(), + fn(string $key) => !in_array(substr($key, strlen($this->prefix) + 1), ['seq', 'queues', 'q:default:ready', 'q:default:delayed', 'q:default:reserved'], true) + ); + + $this->assertSame([], array_values($leftovers), 'job hashes and unique keys must be removed'); + } + + public function testClearLeavesNoJobOrUniqueKeysBehind(): void + { + for ($i = 0; $i < 20; $i++) { + $this->push("j{$i}", unique: "u{$i}"); + } + + $this->driver->clear('default'); + + foreach ($this->allKeys() as $key) { + $this->assertStringNotContainsString(':job:', $key); + $this->assertStringNotContainsString(':unique:', $key); + } + } + + public function testWorksAfterTheServerScriptCacheIsFlushed(): void + { + $this->push('a'); + $this->assertNotNull($this->driver->pop('default')); + + // Every script is now unknown to the server; the driver must fall back + // to sending the source instead of failing with NOSCRIPT. + $this->client->script('FLUSH'); + + $this->assertTrue($this->push('b')); + $this->assertSame('payload-b', $this->driver->pop('default')?->payload); + } + + public function testAJobWhoseHashWasLostIsSkippedNotClaimed(): void + { + $this->push('lost'); + $this->push('kept'); + + // Simulate an eviction or manual deletion of the job data. + foreach ($this->allKeys() as $key) { + if (str_contains($key, ':job:') && $this->client->hget($key, 'payload') === 'payload-lost') { + $this->client->del([$key]); + } + } + + $this->assertSame(['payload-kept'], $this->drain()); + } + + public function testReadyOrderingSurvivesLargeSequenceNumbers(): void + { + // Score = (100 - priority) * 1e12 + sequence; jump the counter close to the + // top of a band and check ordering between priorities still holds. + $this->client->set($this->prefix . ':seq', 900_000_000_000); + + $this->push('low', priority: 0); + $this->push('high', priority: 1); + + $this->assertSame(['payload-high', 'payload-low'], $this->drain()); + } + + public function testJobsFromSeparatePrefixesDoNotMix(): void + { + $other = new RedisDriver( + ['prefix' => '{dqtest_other_' . bin2hex(random_bytes(4)) . '}', 'lease' => self::LEASE], + $this->client, + fn(): int => $this->now + ); + + $this->push('mine'); + + $this->assertNull($other->pop('default')); + $this->assertSame(['payload-mine'], $this->drain()); + } + + public function testBuildsItsOwnClientFromConnectionConfig(): void + { + $driver = new RedisDriver([ + 'connection' => getenv('QUEUE_TEST_REDIS_URL') ?: 'redis://127.0.0.1:6379', + 'options' => ['parameters' => ['database' => (int) (getenv('QUEUE_TEST_REDIS_DB') ?: 15)]], + 'prefix' => $this->prefix, + 'lease' => self::LEASE, + ], null, fn(): int => $this->now); + + $driver->push(new Envelope('x', 'default', 'payload-x', $this->now, 0, null, $this->now)); + + $this->assertSame('payload-x', $this->driver->pop('default')?->payload); + } +} diff --git a/tests/Mock/Commands/SpiesOnConsole.php b/tests/Mock/Commands/SpiesOnConsole.php new file mode 100644 index 0000000..ec40c7d --- /dev/null +++ b/tests/Mock/Commands/SpiesOnConsole.php @@ -0,0 +1,71 @@ + + */ + public array $givenOptions = []; + + /** + * @var array + */ + public array $lines = []; + + /** + * @var array + */ + public array $errors = []; + + /** + * @var array + */ + public array $tables = []; + + public function withOptions(array $options): static + { + $this->givenOptions = $options; + + return $this; + } + + protected function option($key = null) + { + return $key === null ? $this->givenOptions : ($this->givenOptions[$key] ?? null); + } + + protected function info($string): void + { + $this->lines[] = $string; + } + + protected function error($string): void + { + $this->errors[] = $string; + } + + protected function newLine($count = 1): void + { + } + + protected function displaySuccess(string $message): void + { + $this->lines[] = $message; + } + + protected function executeWithTiming(callable $callback): int + { + return $callback(); + } + + protected function createTable() + { + return $this->tables[] = new TableSpy(); + } +} diff --git a/tests/Mock/Commands/SpyFailedCommand.php b/tests/Mock/Commands/SpyFailedCommand.php new file mode 100644 index 0000000..a7a913d --- /dev/null +++ b/tests/Mock/Commands/SpyFailedCommand.php @@ -0,0 +1,10 @@ +headers = $headers; + + return $this; + } + + public function addRow(array $row): self + { + $this->rows[] = $row; + + return $this; + } + + public function render(): void + { + $this->rendered = true; + } +} diff --git a/tests/Mock/Jobs/BackoffJob.php b/tests/Mock/Jobs/BackoffJob.php new file mode 100644 index 0000000..46d6b24 --- /dev/null +++ b/tests/Mock/Jobs/BackoffJob.php @@ -0,0 +1,25 @@ +backoff = $backoff; + } + + public function handle(): void + { + throw new \RuntimeException('always fails'); + } +} diff --git a/tests/Mock/Jobs/FlakyJob.php b/tests/Mock/Jobs/FlakyJob.php new file mode 100644 index 0000000..7c0a1b2 --- /dev/null +++ b/tests/Mock/Jobs/FlakyJob.php @@ -0,0 +1,43 @@ +failTimes) { + throw new \RuntimeException('flaky failure #' . self::$calls); + } + } + + public function failed(\Throwable $exception): void + { + self::$failedWith[] = $exception->getMessage(); + } + + public static function reset(): void + { + self::$calls = 0; + self::$failedWith = []; + } +} diff --git a/tests/Mock/Jobs/LegacyJob.php b/tests/Mock/Jobs/LegacyJob.php new file mode 100644 index 0000000..9af150e --- /dev/null +++ b/tests/Mock/Jobs/LegacyJob.php @@ -0,0 +1,60 @@ +id; + } + + public function setJobId(string $id): void + { + $this->id = $id; + } + + public function getTimeout(): ?int + { + return null; + } +} diff --git a/tests/Mock/Jobs/OtherUniqueRecordingJob.php b/tests/Mock/Jobs/OtherUniqueRecordingJob.php new file mode 100644 index 0000000..fdbf97f --- /dev/null +++ b/tests/Mock/Jobs/OtherUniqueRecordingJob.php @@ -0,0 +1,7 @@ + + */ + public static array $handled = []; + + /** + * Runs inside handle() when set, to let a test interfere mid-job. + * + * @var \Closure|null + */ + public static ?\Closure $during = null; + + public function __construct(public string $label = 'job') + { + } + + public function handle(): void + { + if (self::$during !== null) { + (self::$during)($this); + } + + self::$handled[] = $this->label; + } + + public static function reset(): void + { + self::$handled = []; + self::$during = null; + } +} diff --git a/tests/Mock/Jobs/SleepJob.php b/tests/Mock/Jobs/SleepJob.php new file mode 100644 index 0000000..a230640 --- /dev/null +++ b/tests/Mock/Jobs/SleepJob.php @@ -0,0 +1,21 @@ +timeout = 30; + } + + public function handle(): void + { + sleep($this->seconds); + } +} diff --git a/tests/Mock/Jobs/UniqueRecordingJob.php b/tests/Mock/Jobs/UniqueRecordingJob.php new file mode 100644 index 0000000..e707953 --- /dev/null +++ b/tests/Mock/Jobs/UniqueRecordingJob.php @@ -0,0 +1,11 @@ +label; + } +} diff --git a/tests/Support/NeedsBackend.php b/tests/Support/NeedsBackend.php new file mode 100644 index 0000000..88d7f39 --- /dev/null +++ b/tests/Support/NeedsBackend.php @@ -0,0 +1,22 @@ +fail("A required test backend is unavailable: {$reason}"); + } + + $this->markTestSkipped($reason); + } +} diff --git a/tests/Support/QueueSchema.php b/tests/Support/QueueSchema.php new file mode 100644 index 0000000..1ce8b98 --- /dev/null +++ b/tests/Support/QueueSchema.php @@ -0,0 +1,119 @@ +exec(" + CREATE TABLE queue_jobs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + queue VARCHAR(255) NOT NULL, + payload LONGTEXT NOT NULL, + attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + priority SMALLINT NOT NULL DEFAULT 0, + reserved_at INT UNSIGNED NULL, + lease_expires_at INT UNSIGNED NULL, + available_at INT UNSIGNED NOT NULL, + unique_key VARCHAR(191) NULL, + created_at INT UNSIGNED NOT NULL, + UNIQUE KEY queue_jobs_unique_key_unique (unique_key), + KEY idx_queue_jobs_queue (queue), + KEY idx_queue_jobs_reserved_at (reserved_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + "); + + $pdo->exec(" + CREATE TABLE failed_jobs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + connection VARCHAR(255) NOT NULL, + queue VARCHAR(255) NOT NULL, + payload LONGTEXT NOT NULL, + exception LONGTEXT NOT NULL, + failed_at INT UNSIGNED NOT NULL, + KEY idx_failed_jobs_failed_at (failed_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + "); + } + + /** + * The same tables as the migration produces on PostgreSQL. + */ + public static function createPgsql(PDO $pdo): void + { + $pdo->exec(" + CREATE TABLE queue_jobs ( + id BIGSERIAL PRIMARY KEY, + queue VARCHAR(255) NOT NULL, + payload TEXT NOT NULL, + attempts SMALLINT NOT NULL DEFAULT 0, + priority SMALLINT NOT NULL DEFAULT 0, + reserved_at INTEGER NULL, + lease_expires_at INTEGER NULL, + available_at INTEGER NOT NULL, + unique_key VARCHAR(191) NULL, + created_at INTEGER NOT NULL + ) + "); + + $pdo->exec("CREATE UNIQUE INDEX queue_jobs_unique_key_unique ON queue_jobs (unique_key)"); + $pdo->exec("CREATE INDEX idx_queue_jobs_queue ON queue_jobs (queue)"); + $pdo->exec("CREATE INDEX idx_queue_jobs_reserved_at ON queue_jobs (reserved_at)"); + + $pdo->exec(" + CREATE TABLE failed_jobs ( + id BIGSERIAL PRIMARY KEY, + connection VARCHAR(255) NOT NULL, + queue VARCHAR(255) NOT NULL, + payload TEXT NOT NULL, + exception TEXT NOT NULL, + failed_at INTEGER NOT NULL + ) + "); + + $pdo->exec("CREATE INDEX idx_failed_jobs_failed_at ON failed_jobs (failed_at)"); + } + + public static function create(PDO $pdo): void + { + $pdo->exec(" + CREATE TABLE queue_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue TEXT NOT NULL, + payload TEXT NOT NULL, + attempts INTEGER DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + reserved_at INTEGER, + lease_expires_at INTEGER, + available_at INTEGER NOT NULL, + unique_key TEXT, + created_at INTEGER NOT NULL + ) + "); + + $pdo->exec("CREATE INDEX idx_queue_reserved ON queue_jobs(queue, reserved_at)"); + $pdo->exec("CREATE UNIQUE INDEX idx_queue_unique_key ON queue_jobs(unique_key)"); + + $pdo->exec(" + CREATE TABLE failed_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection TEXT NOT NULL, + queue TEXT NOT NULL, + payload TEXT NOT NULL, + exception TEXT NOT NULL, + failed_at INTEGER NOT NULL + ) + "); + + $pdo->exec("CREATE INDEX idx_failed_at ON failed_jobs(failed_at)"); + } +} diff --git a/tests/Support/claim_worker.php b/tests/Support/claim_worker.php new file mode 100644 index 0000000..090d056 --- /dev/null +++ b/tests/Support/claim_worker.php @@ -0,0 +1,79 @@ + + * + * Waits for the start barrier, then claims jobs until the queue is empty, + * printing one claimed payload per line. In "crash" mode it claims a single + * job and kills itself with SIGKILL without acknowledging it. + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Doppar\Queue\Drivers\DatabaseDriver; +use Doppar\Queue\Drivers\RedisDriver; +use Phaseolies\Database\Database; +use Predis\Client; + +$spec = json_decode(base64_decode($argv[1]), true, flags: JSON_THROW_ON_ERROR); + +if ($spec['backend'] === 'pgsql') { + $pdo = new PDO($spec['pg_dsn'], $spec['pg_user'], $spec['pg_pass']); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('SET search_path TO ' . $spec['pg_schema']); + (new ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + $driver = new DatabaseDriver(['connection' => 'contract', 'lease' => $spec['lease']]); +} elseif ($spec['backend'] === 'mysql') { + $pdo = new PDO($spec['dsn'], $spec['user'], $spec['pass']); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + (new ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + $driver = new DatabaseDriver(['connection' => 'contract', 'lease' => $spec['lease']]); +} elseif ($spec['backend'] === 'sqlite') { + $pdo = new PDO('sqlite:' . $spec['file']); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_TIMEOUT, 30); + $pdo->exec('PRAGMA busy_timeout = 30000'); + (new ReflectionProperty(Database::class, 'connections'))->setValue(null, ['contract' => $pdo]); + + $driver = new DatabaseDriver(['connection' => 'contract', 'lease' => $spec['lease']]); +} else { + $driver = new RedisDriver( + ['prefix' => $spec['prefix'], 'lease' => $spec['lease']], + new Client($spec['url'], ['parameters' => ['database' => $spec['db']]]) + ); +} + +// Start barrier: every worker begins at the same instant to maximise contention. +while (microtime(true) < $spec['start_at']) { + usleep(200); +} + +if ($spec['mode'] === 'crash') { + $job = $driver->pop('default'); + echo $job?->payload, "\n"; + posix_kill(getmypid(), SIGKILL); +} + +$idle = 0; + +while ($idle < 3) { + $job = $driver->pop('default'); + + if ($job === null) { + $idle++; + usleep(20_000); + continue; + } + + $idle = 0; + echo $job->payload, "\n"; + + // Hold the job for a moment so other workers overlap with this one. + usleep(random_int(0, 400)); + + $driver->delete($job); +} diff --git a/tests/Support/slow_job_worker.php b/tests/Support/slow_job_worker.php new file mode 100644 index 0000000..a8ff697 --- /dev/null +++ b/tests/Support/slow_job_worker.php @@ -0,0 +1,38 @@ + + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Doppar\Queue\QueueManager; +use Doppar\Queue\QueueWorker; +use Doppar\Queue\Tests\Mock\MockContainer; +use Phaseolies\Database\Database; +use Phaseolies\DI\Container; + +$spec = json_decode(base64_decode($argv[1]), true, flags: JSON_THROW_ON_ERROR); + +$container = new MockContainer(); +Container::setInstance($container); +$container->bind('db', fn() => new Database('default')); + +$manager = new QueueManager([ + 'default' => 'redis', + 'connections' => ['redis' => [ + 'driver' => 'redis', + 'connection' => $spec['url'], + 'options' => ['parameters' => ['database' => $spec['db']]], + 'prefix' => $spec['prefix'], + 'lease' => $spec['lease'], + ]], +]); + +$worker = new QueueWorker($manager); +$worker->setLeaseRenewInterval($spec['renew_every']); + +echo $worker->runNextJob('default') ? "processed\n" : "nothing\n"; diff --git a/tests/Unit/JobSerializationStaticStateTest.php b/tests/Unit/JobSerializationStaticStateTest.php new file mode 100644 index 0000000..2f6b78e --- /dev/null +++ b/tests/Unit/JobSerializationStaticStateTest.php @@ -0,0 +1,69 @@ +assertStringNotContainsString('leaked-secret-state', $payload); + $this->assertStringNotContainsString('handled', $payload); + } + + public function testUnserializingAJobLeavesTheLiveStaticStateAlone(): void + { + $payload = serialize(new RecordingJob('x')); + + RecordingJob::$handled = ['first', 'second']; + + unserialize($payload); + + $this->assertSame(['first', 'second'], RecordingJob::$handled); + } + + public function testInstanceStateStillRoundTrips(): void + { + $job = new RecordingJob('kept'); + $job->tries = 7; + $job->onQueue('emails'); + + $restored = unserialize(serialize($job)); + + $this->assertSame('kept', $restored->label); + $this->assertSame(7, $restored->tries); + $this->assertSame('emails', $restored->queue()); + } + + public function testAnOldPayloadThatCarriesStaticStateDoesNotOverwriteTheLiveValue(): void + { + // What the previous version produced: the static property serialized as if + // it were an instance property. + $legacy = 'O:' . strlen(RecordingJob::class) . ':"' . RecordingJob::class . '":2:{' + . 's:5:"label";s:3:"old";' + . 's:7:"handled";a:1:{i:0;s:5:"stale";}}'; + + RecordingJob::$handled = ['live']; + + $job = unserialize($legacy); + + $this->assertSame('old', $job->label); + $this->assertSame(['live'], RecordingJob::$handled); + } +} diff --git a/tests/Unit/JobSettingsTest.php b/tests/Unit/JobSettingsTest.php new file mode 100644 index 0000000..1b5bce1 --- /dev/null +++ b/tests/Unit/JobSettingsTest.php @@ -0,0 +1,92 @@ +assertSame(0, $job->priority()); + $this->assertNull($job->connection()); + $this->assertNull($job->uniqueId()); + $this->assertNull($job->backoff()); + $this->assertSame('default', $job->queue()); + } + + public function testPriorityAndConnectionAreFluent(): void + { + $job = new RecordingJob(); + + $this->assertSame($job, $job->withPriority(30)->onConnection('redis')->onQueue('emails')); + $this->assertSame(30, $job->priority()); + $this->assertSame('redis', $job->connection()); + $this->assertSame('emails', $job->queue()); + } + + public function testAJobDecidesItsOwnUniqueness(): void + { + $this->assertSame('report-7', (new UniqueRecordingJob('report-7'))->uniqueId()); + } + + public function testTheQueueableAttributeSetsPriorityConnectionAndBackoff(): void + { + $job = new QueueableAttributeJob(); + + (new \ReflectionMethod($job, 'applyQueueableAttributes'))->invoke($job); + + $this->assertSame(4, $job->tries()); + $this->assertSame('reports', $job->queue()); + $this->assertSame(25, $job->priority()); + $this->assertSame('memory', $job->connection()); + $this->assertSame([5, 15], $job->backoff()); + } + + public function testAttributeValuesLeaveUnsetOptionsAlone(): void + { + $job = new RecordingJob(); + $job->withPriority(9); + + $this->assertSame(9, $job->priority()); + } + + public static function priorities(): array + { + return [ + 'in range' => [10, 10], + 'zero' => [0, 0], + 'max' => [QueueDriver::PRIORITY_MAX, QueueDriver::PRIORITY_MAX], + 'min' => [QueueDriver::PRIORITY_MIN, QueueDriver::PRIORITY_MIN], + 'above max' => [101, 100], + 'far above max' => [PHP_INT_MAX, 100], + 'below min' => [-101, -100], + 'far below min' => [PHP_INT_MIN, -100], + ]; + } + + #[DataProvider('priorities')] + public function testAnEnvelopeClampsPriorityToTheSupportedRange(int $given, int $expected): void + { + $envelope = new Envelope('id', 'default', 'payload', 100, $given); + + $this->assertSame($expected, $envelope->priority); + } + + public function testAnEnvelopeIsImmutable(): void + { + $envelope = new Envelope('id', 'default', 'payload', 100); + + $this->expectException(\Error::class); + + $envelope->queue = 'other'; + } +} diff --git a/tests/Unit/ModelSerializationTest.php b/tests/Unit/ModelSerializationTest.php index 7eabb0a..fa20ce1 100644 --- a/tests/Unit/ModelSerializationTest.php +++ b/tests/Unit/ModelSerializationTest.php @@ -16,6 +16,7 @@ use Doppar\Queue\Tests\Mock\Models\MockPost; use Doppar\Queue\Tests\Mock\Models\MockComment; use Doppar\Queue\Tests\Mock\MockContainer; +use Doppar\Queue\Tests\Support\QueueSchema; use Doppar\Queue\Tests\Mock\Jobs\TestReportJob; use Doppar\Queue\Tests\Mock\Jobs\TestJobWithFailedCallback; use Doppar\Queue\Tests\Mock\Jobs\TestImageJob; @@ -43,8 +44,8 @@ class ModelSerializationTest extends TestCase protected function setUp(): void { - Container::setInstance(new MockContainer()); - $container = new Container(); + $container = new MockContainer(); + Container::setInstance($container); $container->bind('request', fn() => new Request()); $container->bind('url', fn() => UrlGenerator::class); $container->bind('db', fn() => new Database('default')); @@ -58,7 +59,10 @@ protected function setUp(): void $this->createUserTables(); $this->setupDatabaseConnections(); - $this->manager = new QueueManager(); + $this->manager = new QueueManager([ + 'default' => 'database', + 'connections' => ['database' => ['driver' => 'database', 'connection' => 'default']], + ]); $this->worker = new QueueWorker($this->manager); } @@ -72,38 +76,7 @@ protected function tearDown(): void private function createQueueTables(): void { - // Create queue_jobs table - $this->pdo->exec(" - CREATE TABLE queue_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - queue TEXT NOT NULL, - payload TEXT NOT NULL, - attempts INTEGER DEFAULT 0, - reserved_at INTEGER, - available_at INTEGER NOT NULL, - created_at INTEGER NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE INDEX idx_queue_reserved ON queue_jobs(queue, reserved_at) - "); - - // Create failed_jobs table - $this->pdo->exec(" - CREATE TABLE failed_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection TEXT NOT NULL, - queue TEXT NOT NULL, - payload TEXT NOT NULL, - exception TEXT NOT NULL, - failed_at INTEGER NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE INDEX idx_failed_at ON failed_jobs(failed_at) - "); + QueueSchema::create($this->pdo); } private function createUserTables(): void diff --git a/tests/Unit/QueueCommandsTest.php b/tests/Unit/QueueCommandsTest.php new file mode 100644 index 0000000..2565abc --- /dev/null +++ b/tests/Unit/QueueCommandsTest.php @@ -0,0 +1,277 @@ +now = 1_800_000_000; + RecordingJob::reset(); + + $this->manager = new QueueManager([ + 'default' => 'memory', + 'connections' => [ + 'memory' => ['driver' => 'memory', 'lease' => 60], + 'other' => ['driver' => 'memory', 'lease' => 60], + ], + ], fn(): int => $this->now); + + $container = new MockContainer(); + Container::setInstance($container); + $container->singleton(QueueManager::class, fn() => $this->manager); + $container->alias(QueueManager::class, 'queue.worker'); + } + + protected function tearDown(): void + { + RecordingJob::reset(); + Container::forgetInstance(); + } + + /** + * Fail a job on the given connection and return its failed record id. + */ + private function failJob(RecordingJob $job, ?string $connection = null): int|string + { + $this->manager->push($job, $connection); + $this->manager->markAsFailed($this->manager->pop($job->queue(), $connection), new \RuntimeException('boom'), $connection); + + return $this->manager->connection($connection)->failedJobs()[0]->id; + } + + // ------------------------------------------------------------------ + // queue:retry + // ------------------------------------------------------------------ + + public function testRetryRequeuesEveryFailedJob(): void + { + $this->failJob(new RecordingJob('one')); + $this->failJob(new RecordingJob('two')); + + $command = (new SpyRetryCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertSame(2, $this->manager->size()); + $this->assertSame(0, $this->manager->connection()->countFailed()); + $this->assertCount(2, array_filter($command->lines, fn($l) => str_contains($l, 'Retried job ['))); + } + + public function testRetryByIdRequeuesOnlyThatJob(): void + { + $this->failJob(new RecordingJob('keep-failed')); + $target = $this->failJob(new RecordingJob('retry-me')); + + $command = (new SpyRetryCommand())->withOptions(['id' => (string) $target]); + + $this->assertSame(0, $command->handle()); + $this->assertSame(1, $this->manager->size()); + $this->assertSame(1, $this->manager->connection()->countFailed()); + $this->assertSame('retry-me', $this->manager->unserializeJob($this->manager->pop()->payload)->label); + } + + public function testRetryOfAnUnknownIdFails(): void + { + $command = (new SpyRetryCommand())->withOptions(['id' => '999']); + + $this->assertSame(1, $command->handle()); + $this->assertSame(['Failed job with ID 999 not found.'], $command->errors); + } + + public function testRetryExplainsWhenAUniqueJobIsAlreadyQueued(): void + { + $this->manager->push(new UniqueRecordingJob('k')); + $this->manager->markAsFailed($this->manager->pop(), new \RuntimeException('x')); + $this->manager->push(new UniqueRecordingJob('k')); + $id = $this->manager->connection()->failedJobs()[0]->id; + + $command = (new SpyRetryCommand())->withOptions(['id' => (string) $id]); + + $this->assertSame(1, $command->handle()); + $this->assertStringContainsString('unique job with the same key is already queued', $command->errors[0]); + $this->assertSame(1, $this->manager->connection()->countFailed()); + } + + public function testRetryReportsAnUnreadablePayloadAndKeepsGoing(): void + { + $this->failJob(new RecordingJob('good')); + $this->manager->connection()->failedJobs(); + $reflection = new \ReflectionProperty($this->manager->connection(), 'failed'); + $failed = $reflection->getValue($this->manager->connection()); + $failed[99] = new \Doppar\Queue\Support\FailedJobRecord(99, 'memory', 'default', 'garbage', 'x', $this->now); + $reflection->setValue($this->manager->connection(), $failed); + + $command = (new SpyRetryCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertCount(1, $command->errors); + $this->assertStringContainsString('Failed to retry job ID 99', $command->errors[0]); + $this->assertSame(1, $this->manager->size(), 'the readable job was still requeued'); + } + + public function testRetryCanTargetAnotherConnection(): void + { + $this->failJob(new RecordingJob('remote'), 'other'); + + $command = (new SpyRetryCommand())->withOptions(['connection' => 'other']); + + $this->assertSame(0, $command->handle()); + $this->assertSame(1, $this->manager->size('default', 'other')); + $this->assertSame(0, $this->manager->size('default', 'memory')); + } + + // ------------------------------------------------------------------ + // queue:failed and queue:flush + // ------------------------------------------------------------------ + + public function testFailedSaysSoWhenThereAreNoFailedJobs(): void + { + $command = (new SpyFailedCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertSame(['No failed jobs found.'], $command->lines); + $this->assertSame([], $command->tables); + } + + public function testFailedListsEachJobWithItsClassQueueAndTime(): void + { + $this->failJob((new RecordingJob('x'))->onQueue('emails')); + + $command = (new SpyFailedCommand())->withOptions([]); + $command->handle(); + + $table = $command->tables[0]; + + $this->assertSame(['ID', 'Job', 'Queue', 'Failed At'], $table->headers); + $this->assertCount(1, $table->rows); + $this->assertSame(RecordingJob::class, $table->rows[0][1]); + $this->assertSame('emails', $table->rows[0][2]); + $this->assertSame(date('Y-m-d H:i:s', $this->now), $table->rows[0][3]); + $this->assertTrue($table->rendered); + } + + public function testFailedToleratesAnUnreadablePayload(): void + { + $this->failJob(new RecordingJob('x')); + $reflection = new \ReflectionProperty($this->manager->connection(), 'failed'); + $failed = $reflection->getValue($this->manager->connection()); + $failed[5] = new \Doppar\Queue\Support\FailedJobRecord(5, 'memory', 'default', 'garbage', 'x', $this->now); + $reflection->setValue($this->manager->connection(), $failed); + + $command = (new SpyFailedCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertCount(2, $command->tables[0]->rows); + $this->assertContains(null, array_column($command->tables[0]->rows, 1)); + } + + public function testFlushDeletesEveryFailedJob(): void + { + $this->failJob(new RecordingJob('a')); + $this->failJob(new RecordingJob('b')); + + $command = (new SpyFlushCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertSame(0, $this->manager->connection()->countFailed()); + $this->assertStringContainsString('2 failed job(s) deleted', $command->lines[0]); + } + + public function testFlushByIdDeletesOnlyThatJob(): void + { + $this->failJob(new RecordingJob('keep')); + $target = $this->failJob(new RecordingJob('drop')); + + $command = (new SpyFlushCommand())->withOptions(['id' => (string) $target]); + + $this->assertSame(0, $command->handle()); + $this->assertSame(1, $this->manager->connection()->countFailed()); + $this->assertNull($this->manager->connection()->findFailed($target)); + } + + public function testFlushOfAnUnknownIdFails(): void + { + $command = (new SpyFlushCommand())->withOptions(['id' => '404']); + + $this->assertSame(1, $command->handle()); + $this->assertSame(['Failed job with ID 404 not found.'], $command->errors); + } + + // ------------------------------------------------------------------ + // queue:monitor + // ------------------------------------------------------------------ + + public function testMonitorShowsReadyDelayedAndProcessingPerQueue(): void + { + $this->manager->push((new RecordingJob())->onQueue('emails')); + $this->manager->push((new RecordingJob())->onQueue('emails')); + $this->manager->push((new RecordingJob())->onQueue('emails')->delayFor(100)); + $this->manager->pop('emails'); + $this->manager->push((new RecordingJob())->onQueue('reports')); + $this->failJob(new RecordingJob('f')); + + $command = (new SpyMonitorCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + + [$queues, $failed] = $command->tables; + + $this->assertSame(['Queue', 'Ready', 'Delayed', 'Processing'], $queues->headers); + $this->assertSame([['emails', 1, 1, 1], ['reports', 1, 0, 0]], $queues->rows); + $this->assertSame([['Failed Jobs', 1]], $failed->rows); + } + + public function testMonitorWithNothingQueuedStillRendersTheSummary(): void + { + $command = (new SpyMonitorCommand())->withOptions([]); + + $this->assertSame(0, $command->handle()); + $this->assertSame([], $command->tables[0]->rows); + $this->assertSame([['Failed Jobs', 0]], $command->tables[1]->rows); + } + + // ------------------------------------------------------------------ + // queue:run + // ------------------------------------------------------------------ + + public function testRunProcessesJobsFromTheChosenConnectionUpToTheLimit(): void + { + $this->manager->push(new RecordingJob('remote-1'), 'other'); + $this->manager->push(new RecordingJob('remote-2'), 'other'); + $this->manager->push(new RecordingJob('local'), 'memory'); + + $command = (new SpyRunCommand($this->manager))->withOptions([ + 'queue' => 'default', + 'connection' => 'other', + 'sleep' => '0', + 'memory' => '1024', + 'timeout' => 0, + 'limit' => '2', + ]); + + ob_start(); + $status = $command->handle(); + ob_end_clean(); + + $this->assertSame(0, $status); + $this->assertSame(['remote-1', 'remote-2'], RecordingJob::$handled); + $this->assertSame(1, $this->manager->size('default', 'memory'), 'the other connection was left alone'); + $this->assertStringContainsString('connection: other', implode("\n", $command->lines)); + } +} diff --git a/tests/Unit/QueueManagerTest.php b/tests/Unit/QueueManagerTest.php new file mode 100644 index 0000000..9d28c91 --- /dev/null +++ b/tests/Unit/QueueManagerTest.php @@ -0,0 +1,506 @@ +now = 1_800_000_000; + RecordingJob::reset(); + LegacyJob::$handled = 0; + } + + /** + * @param array $connections + */ + private function manager(array $connections = [], string $default = 'memory'): QueueManager + { + return new QueueManager([ + 'default' => $default, + 'connections' => $connections + [ + 'memory' => ['driver' => 'memory', 'lease' => 60], + 'other' => ['driver' => 'memory', 'lease' => 60], + ], + ], fn(): int => $this->now); + } + + // ------------------------------------------------------------------ + // Configuration and connections + // ------------------------------------------------------------------ + + public function testWithoutConfigurationItFallsBackToTheDatabaseConnection(): void + { + $manager = new QueueManager(); + + $this->assertSame('database', $manager->getDefaultConnection()); + $this->assertInstanceOf(DatabaseDriver::class, $manager->connection()); + } + + public function testBuiltInDriversAreResolvedByName(): void + { + $manager = $this->manager([ + 'db' => ['driver' => 'database'], + 'cache' => ['driver' => 'redis'], + 'ram' => ['driver' => 'array'], + ]); + + $this->assertInstanceOf(DatabaseDriver::class, $manager->connection('db')); + $this->assertInstanceOf(RedisDriver::class, $manager->connection('cache')); + $this->assertInstanceOf(MemoryDriver::class, $manager->connection('ram')); + } + + public function testADriverDefaultsToTheConnectionName(): void + { + $manager = new QueueManager(['default' => 'memory', 'connections' => ['memory' => []]]); + + $this->assertInstanceOf(MemoryDriver::class, $manager->connection()); + } + + public function testConnectionsAreResolvedOnceAndCached(): void + { + $manager = $this->manager(); + + $this->assertSame($manager->connection('memory'), $manager->connection('memory')); + $this->assertSame($manager->connection(), $manager->connection('memory')); + $this->assertNotSame($manager->connection('memory'), $manager->connection('other')); + } + + public function testPurgeRebuildsAConnectionOnNextUse(): void + { + $manager = $this->manager(); + $first = $manager->connection('memory'); + + $manager->purge('memory'); + + $this->assertNotSame($first, $manager->connection('memory')); + + $second = $manager->connection('memory'); + $other = $manager->connection('other'); + $manager->purge(); + + $this->assertNotSame($second, $manager->connection('memory')); + $this->assertNotSame($other, $manager->connection('other')); + } + + public function testAnUnconfiguredConnectionIsRejected(): void + { + $this->expectException(QueueException::class); + $this->expectExceptionMessage('Queue connection [nowhere] is not configured.'); + + $this->manager()->connection('nowhere'); + } + + public function testAnUnsupportedDriverIsRejected(): void + { + $manager = $this->manager(['odd' => ['driver' => 'carrier-pigeon']]); + + $this->expectException(QueueException::class); + $this->expectExceptionMessage('Queue driver [carrier-pigeon] is not supported.'); + + $manager->connection('odd'); + } + + public function testCustomDriversReceiveTheirConfigAndTheClock(): void + { + $manager = $this->manager(['custom' => ['driver' => 'sqs', 'region' => 'eu-west-1']]); + $received = null; + + $manager->extend('sqs', function (array $config, \Closure $clock) use (&$received): QueueDriver { + $received = [$config, $clock()]; + + return new MemoryDriver($config, $clock); + }); + + $this->assertInstanceOf(MemoryDriver::class, $manager->connection('custom')); + $this->assertSame('eu-west-1', $received[0]['region']); + $this->assertSame('custom', $received[0]['name']); + $this->assertSame($this->now, $received[1]); + } + + public function testACustomDriverOverridesABuiltInOne(): void + { + $manager = $this->manager(); + $custom = new MemoryDriver(); + + $manager->extend('memory', fn() => $custom); + + $this->assertSame($custom, $manager->connection('memory')); + } + + public function testRegisteringACustomDriverDropsAlreadyResolvedConnections(): void + { + $manager = $this->manager(); + $before = $manager->connection('memory'); + + $manager->extend('memory', fn() => new MemoryDriver()); + + $this->assertNotSame($before, $manager->connection('memory')); + } + + public function testConfigFromTheContainerIsMergedOverTheBuiltInDefaults(): void + { + $manager = new class extends QueueManager { + protected function config(): array + { + return ['default' => 'memory', 'connections' => ['memory' => ['driver' => 'memory']]]; + } + }; + + $this->assertSame('memory', $manager->getDefaultConnection()); + } + + public function testTheDefaultQueueNameCanBeChanged(): void + { + $manager = $this->manager(); + + $this->assertSame('default', $manager->getDefaultQueue()); + + $manager->setDefaultQueue('emails'); + + $this->assertSame('emails', $manager->getDefaultQueue()); + } + + // ------------------------------------------------------------------ + // Pushing + // ------------------------------------------------------------------ + + public function testPushStampsAJobIdAndStoresAnUnserializablePayload(): void + { + $manager = $this->manager(); + $job = new RecordingJob('hello'); + + $id = $manager->push($job); + + $this->assertStringStartsWith('job_', $id); + $this->assertSame($id, $job->getJobId()); + + $reserved = $manager->pop(); + $restored = $manager->unserializeJob($reserved->payload); + + $this->assertInstanceOf(RecordingJob::class, $restored); + $this->assertSame('hello', $restored->label); + $this->assertSame($id, $restored->getJobId()); + } + + public function testEveryPushGetsADistinctJobId(): void + { + $manager = $this->manager(); + + $ids = []; + for ($i = 0; $i < 200; $i++) { + $ids[] = $manager->push(new RecordingJob()); + } + + $this->assertCount(200, array_unique($ids)); + } + + public function testDelayIsAppliedFromTheInjectedClock(): void + { + $manager = $this->manager(); + $manager->push((new RecordingJob())->delayFor(30)); + + $this->assertNull($manager->pop()); + + $this->now += 30; + + $this->assertNotNull($manager->pop()); + } + + public function testJobQueueDecidesWhereItIsStored(): void + { + $manager = $this->manager(); + $manager->push((new RecordingJob())->onQueue('emails')); + + $this->assertSame(0, $manager->size('default')); + $this->assertSame(1, $manager->size('emails')); + } + + public function testPriorityFromTheJobOrdersClaims(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob('normal')); + $manager->push((new RecordingJob('urgent'))->withPriority(50)); + + $this->assertSame('urgent', $manager->unserializeJob($manager->pop()->payload)->label); + $this->assertSame('normal', $manager->unserializeJob($manager->pop()->payload)->label); + } + + public function testUniqueJobIsRefusedWhileAnotherWithTheSameKeyIsQueued(): void + { + $manager = $this->manager(); + + $this->assertNotNull($manager->push(new UniqueRecordingJob('report-7'))); + $this->assertNull($manager->push(new UniqueRecordingJob('report-7'))); + $this->assertNotNull($manager->push(new UniqueRecordingJob('report-8'))); + + $this->assertSame(2, $manager->size()); + } + + public function testUniqueJobCanBeQueuedAgainOnceTheFirstIsDone(): void + { + $manager = $this->manager(); + $manager->push(new UniqueRecordingJob('report-7')); + + $manager->delete($manager->pop()); + + $this->assertNotNull($manager->push(new UniqueRecordingJob('report-7'))); + } + + public function testUniqueKeysAreScopedPerJobClass(): void + { + $manager = $this->manager(); + + $this->assertNotNull($manager->push(new UniqueRecordingJob('same'))); + $this->assertNotNull($manager->push(new OtherUniqueRecordingJob('same'))); + } + + public function testVeryLongUniqueIdsStayWithinIndexLimits(): void + { + $manager = new class(['default' => 'spy', 'connections' => ['spy' => ['driver' => 'spy']]]) extends QueueManager { + public ?Envelope $last = null; + + public function envelope($job): Envelope + { + return $this->last = $this->envelopeFor($job); + } + }; + + $long = str_repeat('x', 500); + $envelope = $manager->envelope(new UniqueRecordingJob($long)); + + $this->assertLessThanOrEqual(191, strlen($envelope->uniqueKey)); + $this->assertSame($envelope->uniqueKey, $manager->envelope(new UniqueRecordingJob($long))->uniqueKey); + $this->assertNotSame($envelope->uniqueKey, $manager->envelope(new UniqueRecordingJob($long . 'y'))->uniqueKey); + } + + public function testJobsThatOnlyImplementTheOriginalInterfaceStillWork(): void + { + $manager = $this->manager(); + $job = new LegacyJob(); + + $this->assertNotNull($manager->push($job)); + + $reserved = $manager->pop('legacy'); + + $this->assertNotNull($reserved); + $this->assertInstanceOf(LegacyJob::class, $manager->unserializeJob($reserved->payload)); + } + + public function testTheJobsOwnConnectionWinsOverTheArgumentWhichWinsOverTheDefault(): void + { + $manager = $this->manager(); + + $manager->push((new RecordingJob('a'))->onConnection('other')); + $manager->push(new RecordingJob('b'), 'other'); + $manager->push((new RecordingJob('c'))->onConnection('memory'), 'other'); + $manager->push(new RecordingJob('d')); + + $this->assertSame(2, $manager->size('default', 'other')); + $this->assertSame(2, $manager->size('default', 'memory')); + } + + public function testAFailingDriverIsReportedAsAQueueException(): void + { + $manager = $this->manager(); + $manager->extend('memory', fn() => new class extends MemoryDriver { + public function push(Envelope $envelope): bool + { + throw new \RuntimeException('backend down'); + } + }); + + try { + $manager->push(new RecordingJob()); + $this->fail('push should have thrown'); + } catch (QueueException $e) { + $this->assertStringContainsString('backend down', $e->getMessage()); + $this->assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + } + } + + public function testPushManyStoresJobsInOrderAndGroupsThemByConnection(): void + { + $manager = $this->manager(); + + $stored = $manager->pushMany([ + new RecordingJob('a'), + (new RecordingJob('b'))->onConnection('other'), + new RecordingJob('c'), + (new RecordingJob('d'))->onConnection('other'), + ]); + + $this->assertSame(4, $stored); + $this->assertSame(2, $manager->size('default', 'memory')); + $this->assertSame(2, $manager->size('default', 'other')); + $this->assertSame('a', $manager->unserializeJob($manager->pop()->payload)->label); + $this->assertSame('c', $manager->unserializeJob($manager->pop()->payload)->label); + $this->assertSame('b', $manager->unserializeJob($manager->pop('default', 'other')->payload)->label); + } + + public function testPushManySkipsRefusedDuplicatesInTheCount(): void + { + $manager = $this->manager(); + $manager->push(new UniqueRecordingJob('k')); + + $this->assertSame(1, $manager->pushMany([ + new UniqueRecordingJob('k'), + new UniqueRecordingJob('fresh'), + ])); + } + + public function testPushManyWithNoJobsStoresNothing(): void + { + $this->assertSame(0, $this->manager()->pushMany([])); + } + + // ------------------------------------------------------------------ + // Consuming, stats and the failed store + // ------------------------------------------------------------------ + + public function testPopAcceptsQueueListsInPriorityOrder(): void + { + $manager = $this->manager(); + $manager->push((new RecordingJob('low'))->onQueue('low')); + $manager->push((new RecordingJob('high'))->onQueue('high')); + + $this->assertSame('high', $manager->unserializeJob($manager->pop('high,low')->payload)->label); + $this->assertSame('low', $manager->unserializeJob($manager->pop(['high', 'low'])->payload)->label); + } + + public function testDeleteReleaseAndExtendLeaseAct(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob()); + + $reserved = $manager->pop(); + $this->assertTrue($manager->extendLease($reserved, 120)); + $this->assertTrue($manager->release($reserved, 5)); + $this->assertSame(1, $manager->stats()['delayed']); + + $this->now += 5; + $again = $manager->pop(); + + $this->assertTrue($manager->delete($again)); + $this->assertSame(0, $manager->size()); + } + + public function testMarkAsFailedStoresAFormattedException(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob()); + + $manager->markAsFailed($manager->pop(), new \DomainException('bad things')); + + $record = $manager->connection()->failedJobs()[0]; + + $this->assertStringStartsWith('DomainException: bad things in ', $record->exception); + $this->assertStringContainsString('Stack trace:', $record->exception); + $this->assertSame($this->now, $record->failedAt); + } + + public function testStatsSizeAndClearPassThrough(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob()); + $manager->push((new RecordingJob())->delayFor(50)); + + $this->assertSame(['ready' => 1, 'delayed' => 1, 'reserved' => 0], $manager->stats()); + $this->assertSame(2, $manager->size()); + $this->assertSame(2, $manager->clear()); + $this->assertSame(0, $manager->size()); + } + + public function testRetryFailedRequeuesTheJobWithFreshAttemptsAndForgetsTheRecord(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob('again')); + $reserved = $manager->pop(); + $manager->markAsFailed($reserved, new \RuntimeException('x')); + + $record = $manager->connection()->failedJobs()[0]; + + $this->assertTrue($manager->retryFailed($record->id)); + + $this->assertSame(0, $manager->connection()->countFailed()); + + $requeued = $manager->pop(); + $job = $manager->unserializeJob($requeued->payload); + + $this->assertSame('again', $job->label); + $this->assertSame(0, $job->attempts); + $this->assertSame(1, $requeued->attempts); + } + + public function testRetryFailedAcceptsARecordDirectly(): void + { + $manager = $this->manager(); + $manager->push(new RecordingJob()); + $manager->markAsFailed($manager->pop(), new \RuntimeException('x')); + + $this->assertTrue($manager->retryFailed($manager->connection()->failedJobs()[0])); + $this->assertSame(1, $manager->size()); + } + + public function testRetryFailedOfAnUnknownIdIsFalse(): void + { + $this->assertFalse($this->manager()->retryFailed(12345)); + } + + public function testRetryFailedKeepsTheRecordWhenAUniqueJobIsAlreadyQueued(): void + { + $manager = $this->manager(); + $manager->push(new UniqueRecordingJob('k')); + $manager->markAsFailed($manager->pop(), new \RuntimeException('x')); + $manager->push(new UniqueRecordingJob('k')); + + $record = $manager->connection()->failedJobs()[0]; + + $this->assertFalse($manager->retryFailed($record)); + $this->assertSame(1, $manager->connection()->countFailed(), 'the failed record must not be lost'); + $this->assertSame(1, $manager->size()); + } + + public function testRetryFailedRejectsAnUnreadablePayload(): void + { + $manager = $this->manager(); + $record = new FailedJobRecord(1, 'memory', 'default', 'not a serialized job', 'x', $this->now); + + $this->expectException(QueueException::class); + + $manager->retryFailed($record); + } + + public function testUnserializingGarbageIsAQueueException(): void + { + $this->expectException(QueueException::class); + + $this->manager()->unserializeJob('garbage'); + } + + public function testReservedJobsAreImmutableValueObjects(): void + { + $reserved = new ReservedJob(1, 'q', 'p', 1, 100, 160); + + $this->expectException(\Error::class); + + $reserved->attempts = 5; + } +} diff --git a/tests/Unit/QueueSystemTest.php b/tests/Unit/QueueSystemTest.php index e3cfa13..0eedeb9 100644 --- a/tests/Unit/QueueSystemTest.php +++ b/tests/Unit/QueueSystemTest.php @@ -13,6 +13,7 @@ use Doppar\Queue\Tests\Mock\Models\MockQueueJob; use Doppar\Queue\Tests\Mock\Models\MockFailedJob; use Doppar\Queue\Tests\Mock\MockContainer; +use Doppar\Queue\Tests\Support\QueueSchema; use Doppar\Queue\Tests\Mock\Jobs\TestReportJob; use Doppar\Queue\Tests\Mock\Jobs\TestJobWithFailedCallback; use Doppar\Queue\Tests\Mock\Jobs\TestImageJob; @@ -38,8 +39,8 @@ class QueueSystemTest extends TestCase protected function setUp(): void { - Container::setInstance(new MockContainer()); - $container = new Container(); + $container = new MockContainer(); + Container::setInstance($container); $container->bind('request', fn() => new Request()); $container->bind('url', fn() => UrlGenerator::class); $container->bind('db', fn() => new Database('default')); @@ -52,7 +53,10 @@ protected function setUp(): void $this->createQueueTables(); $this->setupDatabaseConnections(); - $this->manager = new QueueManager(); + $this->manager = new QueueManager([ + 'default' => 'database', + 'connections' => ['database' => ['driver' => 'database', 'connection' => 'default']], + ]); $this->worker = new QueueWorker($this->manager); TestChainJobA::reset(); @@ -71,38 +75,7 @@ protected function tearDown(): void private function createQueueTables(): void { - // Create queue_jobs table - $this->pdo->exec(" - CREATE TABLE queue_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - queue TEXT NOT NULL, - payload TEXT NOT NULL, - attempts INTEGER DEFAULT 0, - reserved_at INTEGER, - available_at INTEGER NOT NULL, - created_at INTEGER NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE INDEX idx_queue_reserved ON queue_jobs(queue, reserved_at) - "); - - // Create failed_jobs table - $this->pdo->exec(" - CREATE TABLE failed_jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection TEXT NOT NULL, - queue TEXT NOT NULL, - payload TEXT NOT NULL, - exception TEXT NOT NULL, - failed_at INTEGER NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE INDEX idx_failed_at ON failed_jobs(failed_at) - "); + QueueSchema::create($this->pdo); } private function setupDatabaseConnections(): void diff --git a/tests/Unit/QueueWorkerTest.php b/tests/Unit/QueueWorkerTest.php new file mode 100644 index 0000000..375f251 --- /dev/null +++ b/tests/Unit/QueueWorkerTest.php @@ -0,0 +1,552 @@ +now = 1_800_000_000; + RecordingJob::reset(); + FlakyJob::reset(); + + $this->manager = new QueueManager([ + 'default' => 'memory', + 'connections' => [ + 'memory' => ['driver' => 'memory', 'lease' => self::LEASE], + 'other' => ['driver' => 'memory', 'lease' => self::LEASE], + ], + ], fn(): int => $this->now); + + // Job::dispatch(), chains and retries reach the manager through the facade. + $container = new MockContainer(); + Container::setInstance($container); + $container->singleton(QueueManager::class, fn() => $this->manager); + $container->alias(QueueManager::class, 'queue.worker'); + + $this->worker = new QueueWorker($this->manager); + } + + protected function tearDown(): void + { + RecordingJob::reset(); + Container::forgetInstance(); + } + + private function driver(): QueueDriver + { + return $this->manager->connection(); + } + + private function advance(int $seconds): void + { + $this->now += $seconds; + } + + /** + * Run one job, capturing what the worker prints. + */ + private function runOne(string|array $queue = 'default'): bool + { + ob_start(); + + try { + return $this->worker->runNextJob($queue); + } finally { + $this->output .= ob_get_clean(); + } + } + + /** + * Run jobs until nothing more can be claimed right now. + */ + private function runAll(string|array $queue = 'default'): int + { + $count = 0; + + while ($count < 100 && $this->runOne($queue)) { + $count++; + } + + return $count; + } + + // ------------------------------------------------------------------ + // Happy path + // ------------------------------------------------------------------ + + public function testAnEmptyQueueProcessesNothing(): void + { + $this->assertFalse($this->runOne()); + $this->assertSame(0, $this->worker->getJobsProcessed()); + } + + public function testAJobIsRunAndRemovedFromTheQueue(): void + { + $this->manager->push(new RecordingJob('a')); + + $this->assertTrue($this->runOne()); + + $this->assertSame(['a'], RecordingJob::$handled); + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver()->stats('default')); + $this->assertSame(1, $this->worker->getJobsProcessed()); + $this->assertSame(0, $this->driver()->countFailed()); + } + + public function testJobsRunInPriorityThenArrivalOrder(): void + { + $this->manager->push(new RecordingJob('normal-1')); + $this->manager->push((new RecordingJob('urgent'))->withPriority(90)); + $this->manager->push(new RecordingJob('normal-2')); + $this->manager->push((new RecordingJob('low'))->withPriority(-10)); + + $this->runAll(); + + $this->assertSame(['urgent', 'normal-1', 'normal-2', 'low'], RecordingJob::$handled); + } + + public function testQueuesAreConsumedInTheOrderGiven(): void + { + $this->manager->push((new RecordingJob('low'))->onQueue('low')); + $this->manager->push((new RecordingJob('default'))->onQueue('default')); + $this->manager->push((new RecordingJob('high'))->onQueue('high')); + + $this->runAll('high,default,low'); + + $this->assertSame(['high', 'default', 'low'], RecordingJob::$handled); + } + + public function testEachRunWorksOnTheHighestPriorityQueueThatHasWork(): void + { + $this->manager->push((new RecordingJob('low-1'))->onQueue('low')); + $this->manager->push((new RecordingJob('low-2'))->onQueue('low')); + + $this->runOne('high,low'); + $this->manager->push((new RecordingJob('high-arrives'))->onQueue('high')); + $this->runAll('high,low'); + + $this->assertSame(['low-1', 'high-arrives', 'low-2'], RecordingJob::$handled); + } + + public function testProcessingCallbacksSeeTheJob(): void + { + $seen = []; + $this->worker->setOnJobProcessing(function ($job) use (&$seen) { + $seen[] = "before:{$job->label}:attempt{$job->attempts}"; + }); + $this->worker->setOnJobProcessed(function ($job) use (&$seen) { + $seen[] = "after:{$job->label}"; + }); + + $this->manager->push(new RecordingJob('cb')); + $this->runOne(); + + $this->assertSame(['before:cb:attempt1', 'after:cb'], $seen); + } + + public function testTheWorkerCanConsumeANamedConnection(): void + { + $this->manager->push(new RecordingJob('on-other'), 'other'); + + $this->assertFalse($this->runOne(), 'the default connection is empty'); + + $this->worker->setConnection('other'); + + $this->assertTrue($this->runOne()); + $this->assertSame(['on-other'], RecordingJob::$handled); + $this->assertSame(0, $this->manager->size('default', 'other')); + } + + public function testTheConnectionOptionIsReadFromDaemonOptions(): void + { + $method = new \ReflectionMethod($this->worker, 'configureOptions'); + $method->invoke($this->worker, ['connection' => 'other', 'sleep' => 0]); + + $this->manager->push(new RecordingJob('via-options'), 'other'); + + $this->assertTrue($this->runOne()); + } + + // ------------------------------------------------------------------ + // Failure, retries and backoff + // ------------------------------------------------------------------ + + public function testAFailingJobIsReleasedAfterItsRetryAfterAndRetried(): void + { + $this->manager->push(new FlakyJob(failTimes: 2)); // tries 3, retryAfter 10 + + $this->assertTrue($this->runOne()); + $this->assertSame(1, $this->driver()->stats('default')['delayed'], 'released, waiting for retryAfter'); + $this->assertFalse($this->runOne(), 'not available yet'); + + $this->advance(10); + $this->assertTrue($this->runOne()); + $this->assertSame(1, $this->driver()->stats('default')['delayed']); + + $this->advance(10); + $this->assertTrue($this->runOne(), 'third attempt succeeds'); + + $this->assertSame(3, FlakyJob::$calls); + $this->assertSame(['ready' => 0, 'delayed' => 0, 'reserved' => 0], $this->driver()->stats('default')); + $this->assertSame(0, $this->driver()->countFailed()); + } + + public function testAJobThatExhaustsItsTriesIsMovedToTheFailedStoreAndToldSo(): void + { + $this->manager->push(new FlakyJob(failTimes: 99)); // tries 3 + + $this->runOne(); + $this->advance(10); + $this->runOne(); + $this->advance(10); + $this->runOne(); + + $this->assertSame(3, FlakyJob::$calls); + $this->assertSame(['flaky failure #3'], FlakyJob::$failedWith, 'failed() is called once, with the last error'); + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertSame(0, $this->driver()->size('default')); + + $record = $this->driver()->failedJobs()[0]; + $this->assertStringContainsString('flaky failure #3', $record->exception); + $this->assertInstanceOf(FlakyJob::class, $this->manager->unserializeJob($record->payload)); + } + + public function testASingleTryJobFailsImmediately(): void + { + $job = new FlakyJob(failTimes: 99); + $job->tries = 1; + $this->manager->push($job); + + $this->runOne(); + + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertSame(0, $this->driver()->stats('default')['delayed']); + } + + public function testAJobWithZeroTriesStillRunsOnceThenFails(): void + { + $job = new FlakyJob(failTimes: 99); + $job->tries = 0; + $this->manager->push($job); + + $this->runOne(); + + $this->assertSame(1, FlakyJob::$calls); + $this->assertSame(1, $this->driver()->countFailed()); + } + + public function testBackoffListWaitsLongerEachTimeAndRepeatsItsLastValue(): void + { + $this->manager->push(new BackoffJob([10, 60, 300])); // tries 5 + + $expected = [10, 60, 300, 300]; + + foreach ($expected as $attempt => $wait) { + $this->assertTrue($this->runOne(), 'attempt ' . ($attempt + 1)); + + $this->advance($wait - 1); + $this->assertFalse($this->runOne(), "must still be waiting ({$wait}s) after attempt " . ($attempt + 1)); + + $this->advance(1); + } + + $this->assertTrue($this->runOne(), 'fifth and last attempt'); + $this->assertSame(1, $this->driver()->countFailed()); + } + + public function testAnIntegerBackoffIsAFixedWait(): void + { + $this->manager->push(new BackoffJob(25)); + + $this->runOne(); + + $this->advance(24); + $this->assertFalse($this->runOne()); + $this->advance(1); + $this->assertTrue($this->runOne()); + } + + public function testWithoutABackoffTheRetryAfterIsUsed(): void + { + $this->manager->push(new BackoffJob(null)); // retryAfter 999 + + $this->runOne(); + + $this->advance(998); + $this->assertFalse($this->runOne()); + $this->advance(1); + $this->assertTrue($this->runOne()); + } + + public function testAnEmptyBackoffListFallsBackToRetryAfter(): void + { + $this->manager->push(new BackoffJob([])); + + $this->runOne(); + + $this->advance(998); + $this->assertFalse($this->runOne()); + } + + public function testANegativeBackoffMeansNoWait(): void + { + $this->manager->push(new BackoffJob(-5)); + + $this->runOne(); + + $this->assertTrue($this->runOne(), 'released with no delay'); + } + + // ------------------------------------------------------------------ + // Crashes, lost leases and broken payloads + // ------------------------------------------------------------------ + + public function testAJobThatKeepsCrashingItsWorkerIsFailedInsteadOfLoopingForever(): void + { + $this->manager->push(new RecordingJob('crasher')); // tries 1 + + // A worker claims the job and dies without acknowledging it. + $this->manager->pop(); + $this->advance(self::LEASE); + + $this->assertTrue($this->runOne()); + + $this->assertSame([], RecordingJob::$handled, 'a job over its attempts must not run again'); + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertStringContainsString('MaxAttemptsExceededException', $this->driver()->failedJobs()[0]->exception); + $this->assertSame(0, $this->driver()->size('default')); + } + + public function testACrashedJobWithTriesLeftIsRunByTheNextWorker(): void + { + $job = new RecordingJob('survivor'); + $job->tries = 3; + $this->manager->push($job); + + $this->manager->pop(); + $this->advance(self::LEASE); + + $this->assertTrue($this->runOne()); + $this->assertSame(['survivor'], RecordingJob::$handled); + $this->assertSame(0, $this->driver()->countFailed()); + } + + public function testFinishingAfterTheLeaseWasLostDoesNotBreakTheJobsNewOwner(): void + { + $this->manager->push(new RecordingJob('slow')); + $stolen = null; + + RecordingJob::$during = function () use (&$stolen) { + // The job runs so long that its lease expires and another worker takes it. + $this->advance(self::LEASE); + $stolen = $this->manager->pop(); + }; + + $processed = []; + $this->worker->setOnJobProcessed(function ($job) use (&$processed) { + $processed[] = $job->label; + }); + + $this->assertTrue($this->runOne()); + + $this->assertStringContainsString('lease was lost', $this->output); + $this->assertSame([], $processed, 'follow-up work is left to the run that removes the job'); + $this->assertNotNull($stolen); + $this->assertSame(2, $stolen->attempts); + $this->assertSame(1, $this->driver()->stats('default')['reserved'], 'the new owner still holds the job'); + $this->assertSame(0, $this->driver()->countFailed()); + $this->assertTrue($this->driver()->delete($stolen), 'and can still finish it'); + } + + public function testAFailingRemovalAfterSuccessDoesNotRetryOrFailTheJob(): void + { + $this->manager->extend('memory', fn(array $config, \Closure $clock) => new class($config, $clock) extends MemoryDriver { + public function delete(ReservedJob $job): bool + { + throw new \RuntimeException('backend went away'); + } + }); + + $this->manager->push(new RecordingJob('ran')); + + $this->assertTrue($this->runOne()); + + $this->assertSame(['ran'], RecordingJob::$handled); + $this->assertStringContainsString('could not be removed from the queue: backend went away', $this->output); + $this->assertSame(0, $this->driver()->countFailed(), 'the job succeeded, it must not be failed'); + $this->assertSame(0, $this->driver()->stats('default')['delayed'], 'nor released for a retry'); + $this->assertSame(1, $this->driver()->stats('default')['reserved'], 'it stays leased and reappears when the lease expires'); + } + + public function testAnUnreadablePayloadIsFailedWithoutCrashingTheWorker(): void + { + $this->driver()->push(new Envelope('junk', 'default', 'not a serialized job', $this->now, 0, null, $this->now)); + $this->manager->push(new RecordingJob('after-junk')); + + $this->assertSame(2, $this->runAll()); + + $this->assertSame(['after-junk'], RecordingJob::$handled); + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertStringContainsString('does not contain a valid job', $this->driver()->failedJobs()[0]->exception); + } + + // ------------------------------------------------------------------ + // Uniqueness and chains + // ------------------------------------------------------------------ + + public function testAUniqueJobCanBeDispatchedAgainOnceItHasRun(): void + { + $this->assertNotNull($this->manager->push(new UniqueRecordingJob('report'))); + $this->assertNull($this->manager->push(new UniqueRecordingJob('report'))); + + $this->runOne(); + + $this->assertNotNull($this->manager->push(new UniqueRecordingJob('report'))); + } + + public function testAFailedUniqueJobFreesItsKey(): void + { + $unique = new UniqueRecordingJob('report'); + $unique->tries = 1; + RecordingJob::$during = fn() => throw new \RuntimeException('nope'); + + $this->manager->push($unique); + $this->runOne(); + + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertNotNull($this->manager->push(new UniqueRecordingJob('report'))); + } + + public function testChainedJobsRunInOrderAcrossWorkerRuns(): void + { + Drain::conduct([new RecordingJob('one'), new RecordingJob('two'), new RecordingJob('three')])->dispatch(); + + $this->assertSame(1, $this->driver()->size('default'), 'only the first job is queued up front'); + + $this->assertSame(3, $this->runAll()); + $this->assertSame(['one', 'two', 'three'], RecordingJob::$handled); + } + + public function testAChainStopsAtTheFirstFailure(): void + { + $failing = new FlakyJob(failTimes: 99); + $failing->tries = 1; + + Drain::conduct([new RecordingJob('one'), $failing, new RecordingJob('never')])->dispatch(); + + $this->runAll(); + + $this->assertSame(['one'], RecordingJob::$handled); + $this->assertSame(1, $this->driver()->countFailed()); + $this->assertSame(0, $this->driver()->size('default')); + } + + public function testAChainedJobKeepsTheConnectionOfItsPredecessor(): void + { + $first = (new RecordingJob('one'))->onConnection('other'); + + Drain::conduct([$first, new RecordingJob('two')])->dispatch(); + + $this->worker->setConnection('other'); + + $this->assertSame(2, $this->runAll()); + $this->assertSame(['one', 'two'], RecordingJob::$handled); + $this->assertSame(0, $this->manager->size('default', 'memory')); + } + + // ------------------------------------------------------------------ + // Lease renewal + // ------------------------------------------------------------------ + + private function startLongJob(int $renewedSecondsAgo): void + { + $this->manager->push(new RecordingJob('long')); + $reserved = $this->manager->pop(); + + $this->setProperty('reservation', $reserved); + $this->setProperty('leaseRenewedAt', time() - $renewedSecondsAgo); + } + + private function setProperty(string $name, mixed $value): void + { + (new \ReflectionProperty(QueueWorker::class, $name))->setValue($this->worker, $value); + } + + private function renewLease(): void + { + (new \ReflectionMethod($this->worker, 'renewLease'))->invoke($this->worker); + } + + public function testTheLeaseOfALongRunningJobIsRenewed(): void + { + $this->startLongJob(renewedSecondsAgo: 100); + + $this->advance(50); + $this->renewLease(); // lease now runs to +110s + + $this->advance(30); // +80s: past the original lease, inside the renewed one + + $this->assertNull($this->manager->pop(), 'no other worker may take the job'); + } + + public function testTheLeaseIsNotRenewedMoreOftenThanNeeded(): void + { + $this->startLongJob(renewedSecondsAgo: 0); + + $this->advance(50); + $this->renewLease(); // too soon: does nothing + + $this->advance(11); // +61s: the original lease has run out + + $this->assertNotNull($this->manager->pop()); + } + + public function testRenewingWithoutARunningJobDoesNothing(): void + { + $this->renewLease(); + + $this->assertSame(0, $this->driver()->size('default')); + } + + public function testAFailedRenewalIsLoggedNotThrown(): void + { + $this->startLongJob(renewedSecondsAgo: 100); + $this->manager->extend('memory', fn(array $config, \Closure $clock) => new class($config, $clock) extends MemoryDriver { + public function extend(ReservedJob $job, int $seconds): bool + { + throw new \RuntimeException('redis timeout'); + } + }); + + ob_start(); + $this->renewLease(); + $this->output .= ob_get_clean(); + + $this->assertStringContainsString('Could not renew job lease: redis timeout', $this->output); + } +}