diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ade3aba..4d445eb 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,9 +1,9 @@ + * (c) 2026 Dimitri Sitchet Tomkeu * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. @@ -42,5 +42,5 @@ 'BlitzPHP Queue', 'Dimitri Sitchet Tomkeu', 'devcode.dst@gmail.com', - 2026 + 2026, ); diff --git a/README.md b/README.md index ec296bd..386f3bf 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Créez votre premier Job via la commande: php klinge queue:job Example ``` -Et ajoutez-le au tableau des gestionnaires (`handlers`) dans le fichier `app\Config\queue.php`: +Et ajoutez-le au tableau des gestionnaires (`jobs`) dans le fichier `app\Config\queue.php`: ```php // ... @@ -62,7 +62,7 @@ use App\Jobs\Example; return [ // --- - 'handlers' => [ + 'jobs' => [ 'my-example' => Example::class ], diff --git a/composer.json b/composer.json index afd678e..65aab65 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "blitz-php/queue", "description": "Gestionnaire de file d'attente pour BlitzPHP", - "keywords": ["blitz-php", "blitz php", "queue", "database", "redis", "predis" ], - "homepage": "https://github.com/blitz-php/tasks", + "keywords": ["blitz-php", "blitz php", "queue", "worker", "database", "redis", "predis", "file d'attente" ], + "homepage": "https://github.com/blitz-php/queue", "license": "MIT", "type": "library", "authors": [ @@ -13,11 +13,12 @@ } ], "require": { - "php": "^8.1" + "php": "^8.1", + "blitz-php/database": "^0.8.3" }, "require-dev": { "blitz-php/coding-standard": "^1.4", - "blitz-php/framework": "^0.11.3", + "blitz-php/framework": "^0.12.4", "kahlan/kahlan": "^6.0", "phpstan/phpstan": "^2.1", "predis/predis": "^2.0 || ^3.0", @@ -37,9 +38,9 @@ } }, "suggest": { - "ext-redis": "If you want to use RedisHandler", - "predis/predis": "If you want to use PredisHandler", - "php-amqplib/php-amqplib": "If you want to use RabbitMQHandler" + "ext-redis": "Si vous souhaitez utiliser RedisDriver", + "predis/predis": "Si vous souhaitez utiliser PredisDriver", + "php-amqplib/php-amqplib": "Si vous souhaitez utiliser RabbitMQDriver" }, "scripts": { "test": "vendor/bin/kahlan", diff --git a/spec/bootstrap.php b/spec/bootstrap.php index 0331466..c0fef57 100644 --- a/spec/bootstrap.php +++ b/spec/bootstrap.php @@ -1,11 +1,10 @@ + * (c) 2026 Dimitri Sitchet Tomkeu * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ - diff --git a/src/CallQueuedClosure.php b/src/CallQueuedClosure.php new file mode 100644 index 0000000..8217ff0 --- /dev/null +++ b/src/CallQueuedClosure.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Queue\Traits\Dispatchable; +use BlitzPHP\Queue\Traits\InteractsWithQueue; +use BlitzPHP\Queue\Traits\SerializesModels; +use Closure; +use Laravel\SerializableClosure\SerializableClosure; +use ReflectionFunction; +use Throwable; + +/** + * Job enveloppe d'une Closure sérialisable, exécutable par le worker. + */ +class CallQueuedClosure +{ + use Dispatchable; + use InteractsWithQueue; + use SerializesModels; + + /** + * Instance de Closure sérialisable. + * + * @var SerializableClosure + */ + public $closure; + + /** + * Nom assigné au job. + */ + public ?string $name = null; + + /** + * Callbacks à exécuter en cas d'échec. + */ + public array $failureCallbacks = []; + + /** + * Indique si le job doit être supprimé lorsque des modèles sont introuvables. + */ + public bool $deleteWhenMissingModels = true; + + /** + * Crée une nouvelle instance de job. + */ + public function __construct(SerializableClosure $closure) + { + $this->closure = $closure; + } + + /** + * Crée une nouvelle instance de job. + */ + public static function create(Closure $job): self + { + return new self(new SerializableClosure($job)); + } + + /** + * Exécute le job. + */ + public function handle(ContainerInterface $container): void + { + $container->call($this->closure->getClosure(), ['job' => $this]); + } + + /** + * Ajoute un callback exécuté si le job échoue. + */ + public function onFailure(callable $callback): self + { + $this->failureCallbacks[] = $callback instanceof Closure + ? new SerializableClosure($callback) + : $callback; + + return $this; + } + + /** + * Traite l'échec du job. + */ + public function failed(Throwable $e): void + { + foreach ($this->failureCallbacks as $callback) { + $callback($e); + } + } + + /** + * Retourne le nom d'affichage du job enfilé. + */ + public function displayName(): string + { + $closure = $this->closure instanceof SerializableClosure + ? $this->closure->getClosure() + : $this->closure; + + $reflection = new ReflectionFunction($closure); + + $prefix = null === $this->name ? '' : "{$this->name} - "; + + return $prefix . 'Closure (' . basename($reflection->getFileName()) . ':' . $reflection->getStartLine() . ')'; + } + + /** + * Assigne un nom au job. + */ + public function name(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/CallQueuedHandler.php b/src/CallQueuedHandler.php new file mode 100644 index 0000000..e62e7db --- /dev/null +++ b/src/CallQueuedHandler.php @@ -0,0 +1,405 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use __PHP_Incomplete_Class; +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Security\EncrypterInterface; +use BlitzPHP\Queue\Exceptions\MaxAttemptsExceededException; +use BlitzPHP\Queue\Failed\FailedJobProviderInterface; +use BlitzPHP\Utilities\Helpers; +use BlitzPHP\Wolke\Exceptions\ModelNotFoundException; +use Exception; +use RuntimeException; +use Throwable; + +/** + * Handler invoqué par le worker pour désérialiser et exécuter un job utilisateur. + */ +class CallQueuedHandler +{ + /** + * Constructeur + */ + public function __construct(protected ContainerInterface $container) + { + } + + /** + * Traite le job enfilé. + * Méthode invoquée par le worker via JobName::parse(). + */ + public function call(Job $job, array $data): void + { + try { + // Récupérer la commande (le job utilisateur) + $command = $this->getCommand($data); + + // Vérifier si c'est une classe incomplète + if ($command instanceof __PHP_Incomplete_Class) { + throw new Exception('Job is incomplete class: ' . json_encode($command)); + } + + // Injecter les dépendances + $command = $this->setJobInstanceIfNecessary($job, $command); + + // Si le job a déjà été supprimé, on arrête + if ($job->isDeleted()) { + return; + } + + // Exécuter le job + $this->executeCommand($command); + + // Si le job n'a pas été supprimé ou relâché, on le supprime + if (! $job->isDeletedOrReleased()) { + $job->delete(); + } + } catch (ModelNotFoundException $e) { + // Gérer le cas où un modèle n'est pas trouvé + $this->handleModelNotFound($job, $e); + } catch (Throwable $e) { + // Gérer les autres exceptions + $this->handleException($job, $data, $e); + + throw $e; + } + } + + /** + * Récupère la commande (le job utilisateur) depuis les données + */ + protected function getCommand(array $data): mixed + { + if (! isset($data['command'])) { + throw new RuntimeException('Job data missing "command" key.'); + } + + // Si c'est déjà un objet (pour les jobs sync) + if (is_object($data['command']) && ! is_string($data['command'])) { + return $data['command']; + } + + // Si c'est une chaîne sérialisée + if (is_string($data['command'])) { + // Vérifier si c'est du sérialisé PHP + if (str_starts_with($data['command'], 'O:')) { + $command = unserialize($data['command']); + if ($command !== false) { + return $command; + } + } + + // Essayer de décrypter si c'est encrypté + if ($this->container->bound(EncrypterInterface::class)) { + try { + $decrypted = $this->container->get(EncrypterInterface::class)->decrypt($data['command']); + $command = unserialize($decrypted); + if ($command !== false) { + return $command; + } + } catch (Throwable $e) { + // Ignorer l'erreur de décryptage + } + } + } + + throw new RuntimeException('Unable to extract job payload.'); + } + + /** + * Attache l'instance de job au handler si le trait InteractsWithQueue est utilisé. + */ + protected function setJobInstanceIfNecessary(Job $job, mixed $instance): mixed + { + // Vérifier si la classe utilise le trait InteractsWithQueue + if (is_object($instance) && $this->usesInteractsWithQueue($instance)) { + if (method_exists($instance, 'setJob')) { + $instance->setJob($job); + } + } + + // Si c'est un CallQueuedClosure, on lui passe le container + if ($instance instanceof CallQueuedClosure) { + // Déjà géré dans executeCommand + } + + return $instance; + } + + /** + * Vérifie si la classe utilise le trait InteractsWithQueue + */ + protected function usesInteractsWithQueue(object $instance): bool + { + $traits = Helpers::classUsesRecursive($instance); + + return isset($traits[Traits\InteractsWithQueue::class]) + || isset($traits['BlitzPHP\\Queue\\Traits\\InteractsWithQueue']); + } + + /** + * Exécute la commande (le job) + */ + protected function executeCommand(object $command): void + { + // Si c'est un CallQueuedClosure + if ($command instanceof CallQueuedClosure) { + $command->handle($this->container); + + return; + } + + // Si le job a une méthode handle() (cas standard) + if (method_exists($command, 'handle')) { + $this->container->call([$command, 'handle']); + + return; + } + + // Si c'est callable (__invoke) + if (is_callable($command)) { + $this->container->call($command); + + return; + } + + throw new RuntimeException( + 'Job does not have a handle() method and is not callable: ' . $command::class, + ); + } + + /** + * Gère une exception pendant l'exécution du job + */ + protected function handleException(Job $job, array $data, Throwable $e): void + { + // Si le job a déjà été marqué comme échoué, on arrête + if ($job->hasFailed()) { + return; + } + + // Récupérer la commande pour les métadonnées + $command = null; + + try { + $command = $this->getCommand($data); + } catch (Throwable $parseError) { + // Ignorer l'erreur de parsing + } + + // Vérifier si le job a dépassé le nombre max de tentatives + $maxTries = $this->getMaxTries($command); + $attempts = $job->attempts(); + + if ($attempts >= $maxTries) { + // Marquer comme échoué + $job->markAsFailed(); + + // Appeler la méthode failed du job si elle existe + if ($command && method_exists($command, 'failed')) { + try { + $command->failed($e); + } catch (Throwable $failedError) { + // Ignorer les erreurs dans failed() + } + } + + // Logger l'échec + logger()->error('Job failed after max attempts', [ + 'job' => $this->getJobName($command, $data), + 'attempts' => $attempts, + 'max_tries' => $maxTries, + 'error' => $e->getMessage(), + 'job_id' => $job->getJobId(), + 'queue' => $job->getQueue(), + ]); + + // Enregistrer dans le provider de jobs échoués + $this->logFailedJob($job, $e); + + // Supprimer le job + $job->delete(); + + throw new MaxAttemptsExceededException( + 'Job failed after ' . $maxTries . ' attempts: ' . $e->getMessage(), + 0, + $e, + ); + } + + // Calculer le backoff + $backoff = $this->calculateBackoff($command, $attempts); + + // Relâcher le job avec backoff + $job->release($backoff); + + logger()->warning('Job released for retry', [ + 'job' => $this->getJobName($command, $data), + 'attempts' => $attempts, + 'backoff' => $backoff, + 'error' => $e->getMessage(), + 'job_id' => $job->getJobId(), + 'queue' => $job->getQueue(), + ]); + } + + /** + * Récupère le nombre max de tentatives + */ + protected function getMaxTries(?object $command): int + { + if ($command === null) { + return config('queue.max_tries', 3); + } + + if (method_exists($command, 'maxTries')) { + $maxTries = $command->maxTries(); + if ($maxTries !== null) { + return (int) $maxTries; + } + } + + if (property_exists($command, 'maxTries')) { + return (int) $command->maxTries; + } + + return config('queue.max_tries', 3); + } + + /** + * Calcule le backoff pour le retry + */ + protected function calculateBackoff(?object $command, int $attempts): int + { + $backoff = 60; // Valeur par défaut + + if ($command !== null) { + if (method_exists($command, 'backoff')) { + $backoffValue = $command->backoff(); + if (is_array($backoffValue)) { + $backoff = $backoffValue[$attempts - 1] ?? $backoffValue[0] ?? 60; + } else { + $backoff = (int) $backoffValue; + } + } elseif (property_exists($command, 'backoff')) { + $backoffValue = $command->backoff; + if (is_array($backoffValue)) { + $backoff = $backoffValue[$attempts - 1] ?? $backoffValue[0] ?? 60; + } else { + $backoff = (int) $backoffValue; + } + } + } + + // Si le backoff est 0, on utilise un backoff exponentiel + if ($backoff === 0) { + $backoff = 60 * 2 ** ($attempts - 1); + } + + return $backoff; + } + + /** + * Récupère le nom du job pour les logs + */ + protected function getJobName(?object $command, array $data): string + { + if ($command !== null) { + return $command::class; + } + + return $data['commandName'] ?? $data['displayName'] ?? 'Unknown'; + } + + /** + * Enregistre le job comme échoué + */ + protected function logFailedJob(Job $job, Throwable $e): void + { + try { + $failedProvider = $this->container->get(FailedJobProviderInterface::class); + + $failedProvider->log( + $job->getConnectionName(), + $job->getQueue(), + $job->getRawBody(), + $e, + ); + } catch (Throwable $logError) { + // Ignorer les erreurs de logging + logger()->error('Failed to log failed job', [ + 'error' => $logError->getMessage(), + 'job_id' => $job->getJobId(), + ]); + } + } + + /** + * Gère le cas où un modèle n'est pas trouvé + */ + protected function handleModelNotFound(Job $job, ModelNotFoundException $e): void + { + $payload = $job->payload(); + + // Vérifier si on doit supprimer le job quand les modèles sont manquants + if (isset($payload['deleteWhenMissingModels']) && $payload['deleteWhenMissingModels']) { + $job->delete(); + logger()->warning('Job deleted because model was not found', [ + 'job_id' => $job->getJobId(), + 'queue' => $job->getQueue(), + 'model' => $e->getModel(), + ]); + + return; + } + + // Sinon, on marque comme échoué + $job->fail($e); + } + + /** + * Méthode appelée quand le job échoue définitivement + * (appelée par le worker après max attempts) + */ + public function failed(array $data, Throwable $e, string $uuid, ?Job $job = null): void + { + try { + $command = $this->getCommand($data); + + if ($command instanceof __PHP_Incomplete_Class) { + return; + } + + if ($job !== null) { + $command = $this->setJobInstanceIfNecessary($job, $command); + } + + // Appeler la méthode failed du job si elle existe + if (is_object($command) && method_exists($command, 'failed')) { + $command->failed($e); + } + + logger()->critical('Job permanently failed', [ + 'job' => $this->getJobName($command ?? null, $data), + 'uuid' => $uuid, + 'error' => $e->getMessage(), + ]); + } catch (Throwable $handledError) { + // Ignorer les erreurs dans failed() + logger()->error('Error in CallQueuedHandler::failed', [ + 'error' => $handledError->getMessage(), + ]); + } + } +} diff --git a/src/Commands/Work.php b/src/Commands/Work.php new file mode 100644 index 0000000..1a16372 --- /dev/null +++ b/src/Commands/Work.php @@ -0,0 +1,403 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Commands; + +use BlitzPHP\Cache\Handlers\BaseHandler; +use BlitzPHP\Cli\Console\Command; +use BlitzPHP\Cli\Console\Console; +use BlitzPHP\Contracts\Cache\CacheInterface; +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Event\EventManagerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Queue\DTO\WorkerOptions; +use BlitzPHP\Queue\Events\QueueEvent; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Worker; +use BlitzPHP\Traits\Support\InteractsWithTime; +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\String\Stringable; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Commande console `queue:work` : traite les jobs en daemon ou un par un. + */ +class Work extends Command +{ + use InteractsWithTime; + + /** + * @var string Groupe auquel appartient la commande + */ + protected $group = 'Queue'; + + /** + * @var string Nom de la commande + */ + protected $name = 'queue:work'; + + /** + * @var string Description de la commande + */ + protected $description = 'Traite les jobs de la file d\'attente en mode daemon'; + + /** + * @var array Arguments de la commande + */ + protected $arguments = [ + 'connection' => 'Nom de la connexion de file à traiter', + ]; + + /** + * @var array Options de la commande + */ + protected $options = [ + '--name' => ['Nom du worker', 'default'], + '--queue' => ['Noms des files à traiter (séparés par des virgules)'], + '--daemon' => ['Exécute le worker en mode daemon (obsolète)'], + '--once' => ['Ne traite que le prochain job de la file'], + '--stop-when-empty' => ['S\'arrête lorsque la file est vide'], + '--delay' => ['Secondes de délai avant retry d\'un job échoué (obsolète)', 0], + '--backoff' => ['Secondes d\'attente avant de relancer un job ayant levé une exception', 0], + '--max-jobs' => ['Nombre de jobs à traiter avant arrêt', 0], + '--max-time' => ['Durée maximale d\'exécution du worker (secondes)', 0], + '--force' => ['Force l\'exécution même en mode maintenance'], + '--memory' => ['Limite mémoire en mégaoctets', 128], + '--sleep' => ['Secondes d\'attente lorsqu\'aucun job n\'est disponible', 3], + '--rest' => ['Secondes de pause entre deux jobs', 0], + '--timeout' => ['Durée maximale d\'un processus enfant (secondes)', 60], + '--tries' => ['Nombre de tentatives avant d\'enregistrer l\'échec', 1], + '--json' => ['Affiche les informations du worker au format JSON'], + ]; + + /** + * Instance du worker de file. + */ + protected Worker $worker; + + /** + * Implémentation du cache. + */ + protected CacheInterface $cache; + + /** + * Gestionnaire d'événements de l'application. + */ + protected EventManagerInterface $events; + + /** + * Horodatage de début du dernier job traité, s'il y en a un. + */ + protected ?float $latestStartedAt = null; + + /** + * Indique si les écouteurs d'événements du worker ont été enregistrés. + */ + private static bool $hasRegisteredListeners = false; + + /** + * Indique si `stty` est disponible (null = pas encore sondé). + */ + private static ?bool $stty = null; + + /** + * Crée la commande de traitement de la file. + */ + public function __construct(protected ContainerInterface $container, protected Console $app) + { + parent::__construct($app, $container->get(LoggerInterface::class)); + + $this->worker = service('worker'); + $this->cache = $container->get(CacheInterface::class); + $this->events = $container->get(EventManagerInterface::class); + + BaseHandler::setReservedCharacters(str_replace(':', '', config('cache.reserved_characters'))); + } + + /** + * Exécute la commande console. + * + * @return int|null + */ + public function execute(array $params) + { + set_time_limit(0); + + if ($this->downForMaintenance() && $this->option('once')) { + return $this->worker->sleep($this->option('sleep')); + } + + // Écoute des événements de succès / échec pour afficher la progression en console. + $this->listenForEvents(); + + $connection = $this->argument('connection') ?: config('queue.default'); + + // File cible : option --queue, sinon valeur de configuration de la connexion. + $queue = $this->getQueue($connection); + + if (! $this->outputUsingJson() && static::terminalHasSttyAvailable()) { + $this->info( + sprintf('Processing jobs from the [%s] %s.', $queue, (new Stringable('queue'))->plural(explode(',', $queue))), + ); + } + + return $this->runWorker( + $connection, + $queue, + ); + } + + /** + * Lance l'instance du worker. + */ + protected function runWorker(string $connection, string $queue): ?int + { + return $this->worker + ->setName($this->option('name')) + ->setCache($this->cache) + ->{$this->option('once') ? 'runNextJob' : 'daemon'}( + $connection, + $queue, + $this->gatherWorkerOptions() + ); + } + + /** + * Regroupe les options du worker dans un seul objet. + */ + protected function gatherWorkerOptions(): WorkerOptions + { + return new WorkerOptions( + $this->option('name'), + max($this->option('backoff'), $this->option('delay')), + $this->option('memory'), + $this->option('timeout'), + $this->option('sleep'), + $this->option('tries'), + $this->option('force', false), + $this->option('stop-when-empty', false), + $this->option('max-jobs'), + $this->option('max-time'), + $this->option('rest'), + ); + } + + /** + * Écoute les événements de file pour mettre à jour la sortie console. + */ + protected function listenForEvents(): void + { + if (static::$hasRegisteredListeners) { + return; + } + + $this->events->on(QueueEventManager::JOB_PROCESSING, function (QueueEvent $event) { + $this->writeOutput($event->job, 'starting'); + }); + + $this->events->on(QueueEventManager::JOB_PROCESSED, function (QueueEvent $event) { + $this->writeOutput($event->job, 'success'); + }); + + $this->events->on(QueueEventManager::JOB_RELEASED_AFTER_EXCEPTION, function (QueueEvent $event) { + $this->writeOutput($event->job, 'released_after_exception'); + }); + + $this->events->on(QueueEventManager::JOB_FAILED, function (QueueEvent $event) { + $this->writeOutput($event->job, 'failed', $event->exception); + + $this->logFailedJob($event); + }); + + static::$hasRegisteredListeners = true; + } + + /** + * Affiche l'état du worker (JSON ou TTY). + */ + protected function writeOutput(Job $job, string $status, ?Throwable $exception = null): void + { + if ($this->isSilent()) { + return; + } + + $this->outputUsingJson() + ? $this->writeOutputAsJson($job, $status, $exception) + : $this->writeOutputForCli($job, $status); + } + + /** + * Affiche l'état du worker dans le terminal. + */ + protected function writeOutputForCli(Job $job, string $status): void + { + $isVerbose = $this->option('verbose'); + + $first = sprintf( + '%s %s %s', + $this->color->comment($this->now()->format('Y-m-d H:i:s')), + $job->resolveName(), + ! $isVerbose ? '' : sprintf( + '%s %s', + $this->color->comment($job->getJobId()), + $this->color->info($job->getConnectionName() . ' ' . $job->getQueue()), + ), + ); + + if ($status === 'starting') { + $this->latestStartedAt = microtime(true); + + $second = $this->color->warn('RUNNING', ['bold' => 1]); + } else { + $runTime = (microtime(true) - $this->latestStartedAt) * 1000; + $runTime = (float) number_format($runTime, 2, '.', ''); + + $memory = $isVerbose ? round(memory_get_usage(true) / 1024 / 1024, 1) . 'MB' : ''; + + $second = $this->color->comment("{$runTime} ms" . ($memory ? " {$memory}" : '') . ' '); + $second .= match ($status) { + 'success' => $this->color->ok('DONE', ['bold' => 1]), + 'released_after_exception' => $this->color->warn('FAIL', ['bold' => 1]), + default => $this->color->error('FAIL', ['bold' => 1]), + }; + } + + $this->justify($first, $second); + } + + /** + * Affiche l'état du worker au format JSON. + * + * @param mixed $status + */ + protected function writeOutputAsJson(Job $job, $status, ?Throwable $exception = null): void + { + $log = array_filter([ + 'level' => $status === 'starting' || $status === 'success' ? 'info' : 'warning', + 'id' => $job->getJobId(), + 'uuid' => $job->uuid(), + 'connection' => $job->getConnectionName(), + 'queue' => $job->getQueue(), + 'job' => $job->resolveName(), + 'status' => $status, + 'result' => match (true) { + $job->isDeleted() => 'deleted', + $job->isReleased() => 'released', + $job->hasFailed() => 'failed', + default => '', + }, + 'attempts' => $job->attempts(), + 'exception' => $exception ? $exception::class : '', + 'message' => $exception?->getMessage(), + 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'), + ]); + + if ($status === 'starting') { + $this->latestStartedAt = microtime(true); + } else { + $log['duration'] = round(microtime(true) - $this->latestStartedAt, 6); + } + + $this->json($log); + } + + /** + * Retourne la date et l'heure courantes. + */ + protected function now(): Date + { + $queueTimezone = config('queue.output_timezone'); + + if ($queueTimezone && $queueTimezone !== config('app.timezone')) { + return Date::now()->setTimezone($queueTimezone); + } + + return Date::now(); + } + + /** + * Enregistre un événement de job échoué. + */ + protected function logFailedJob(QueueEvent $event): void + { + service('queueFailer')->log( + $event->connection, + $event->job->getQueue(), + $event->job->getRawBody(), + $event->exception, + ); + } + + /** + * Retourne le nom de file à traiter par le worker. + */ + protected function getQueue(string $connection): string + { + return $this->option('queue') ?: config( + "queue.connections.{$connection}.queue", + 'default', + ); + } + + /** + * Indique si l'application est en maintenance (et si le worker doit s'arrêter). + */ + protected function downForMaintenance(): false + { + return $this->option('force') + ? false + : config('app.maintenance.enable', false); // $this->laravel->isDownForMaintenance(); + } + + /** + * Indique si la sortie du worker doit être en JSON. + */ + protected function outputUsingJson(): bool + { + return filter_var($this->option('json'), FILTER_VALIDATE_BOOLEAN) === true; + } + + /** + * Réinitialise les variables statiques. + */ + public static function flushState(): void + { + static::$hasRegisteredListeners = false; + } + + /** + * Indique si la sortie console est silencieuse (non CLI ou mode suppress). + */ + protected function isSilent(): bool + { + return $this->suppress || ! is_cli(); + } + + /** + * Indique si le terminal courant prend en charge `stty`. + * + * @internal + */ + protected static function terminalHasSttyAvailable(): bool + { + if (null !== self::$stty) { + return self::$stty; + } + + // Pas de vérification si shell_exec est désactivé + if (! \function_exists('shell_exec')) { + return false; + } + + return self::$stty = (bool) @shell_exec('stty 2> ' . ('\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null')); + } +} diff --git a/src/Compatibility/SignalTrait.php b/src/Compatibility/SignalTrait.php new file mode 100644 index 0000000..cfdf72a --- /dev/null +++ b/src/Compatibility/SignalTrait.php @@ -0,0 +1,411 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Compatibility; + +use Closure; + +if (trait_exists('BlitzPHP\CLI\SignalTrait')) { + trait SignalTrait + { + use \BlitzPHP\CLI\SignalTrait; + } +} else { + /** + * Trait de gestion des signaux. + * + * Fournit la gestion des signaux PCNTL pour les commandes CLI. + * Nécessite l'extension PCNTL (Unix uniquement). + * + * Version de compatibilité fournie pour BlitzPHP < 1.2. + */ + trait SignalTrait + { + /** + * Indique si le processus doit continuer (false = arrêt demandé). + */ + private bool $running = true; + + /** + * Indique si les signaux sont actuellement bloqués. + */ + private bool $signalsBlocked = false; + + /** + * Liste des signaux enregistrés. + * + * @var list + */ + private array $registeredSignals = []; + + /** + * Correspondance signal → méthode. + * + * @var array + */ + private array $signalMethodMap = []; + + /** + * Résultat mis en cache de la disponibilité de l'extension PCNTL. + */ + private static ?bool $isPcntlAvailable = null; + + /** + * Résultat mis en cache de la disponibilité de l'extension POSIX. + */ + private static ?bool $isPosixAvailable = null; + + /** + * Indique si l'extension PCNTL est disponible (valeur mise en cache). + */ + protected function isPcntlAvailable(): bool + { + if (self::$isPcntlAvailable === null) { + if (is_windows()) { + self::$isPcntlAvailable = false; + } else { + self::$isPcntlAvailable = extension_loaded('pcntl'); + if (! self::$isPcntlAvailable) { + // CLI::write('PCNTL extension is not available. Signal handling will be disabled.', 'yellow'); + } + } + } + + return self::$isPcntlAvailable; + } + + /** + * Indique si l'extension POSIX est disponible (valeur mise en cache). + */ + protected function isPosixAvailable(): bool + { + if (self::$isPosixAvailable === null) { + self::$isPosixAvailable = is_windows() ? false : extension_loaded('posix'); + } + + return self::$isPosixAvailable; + } + + /** + * Enregistre les gestionnaires de signaux. + * + * @param list $signals Liste des signaux à traiter. + * @param array $methodMap Correspondance optionnelle signal → méthode. + */ + protected function registerSignals( + array $signals = [], + array $methodMap = [], + ): void { + if (! $this->isPcntlAvailable()) { + return; + } + + if ($signals === []) { + $signals = [SIGTERM, SIGINT, SIGHUP, SIGQUIT]; + } + + if (! $this->isPosixAvailable() && (in_array(SIGTSTP, $signals, true) || in_array(SIGCONT, $signals, true))) { + // CLI::write('POSIX extension is not available. SIGTSTP and SIGCONT signals will be disabled.', 'yellow'); + $signals = array_diff($signals, [SIGTSTP, SIGCONT]); + + // Retire aussi les associations de méthodes + unset($methodMap[SIGTSTP], $methodMap[SIGCONT]); + + if ($signals === []) { + return; + } + } + + // Active les signaux asynchrones pour une réaction immédiate + pcntl_async_signals(true); + + $this->signalMethodMap = $methodMap; + + foreach ($signals as $signal) { + if (pcntl_signal($signal, [$this, 'handleSignal'])) { + $this->registeredSignals[] = $signal; + } else { + $signal = $this->getSignalName($signal); + // CLI::write("Failed to register signal handler for {$signal}.", 'red'); + } + } + } + + /** + * Traite les signaux reçus. + */ + protected function handleSignal(int $signal): void + { + $this->callCustomHandler($signal); + + // Applique le comportement Unix standard pour les signaux enregistrés + switch ($signal) { + case SIGTERM: + case SIGINT: + case SIGQUIT: + case SIGHUP: + $this->running = false; + break; + + case SIGTSTP: + // Restaure le handler par défaut et renvoie le signal pour suspendre vraiment + pcntl_signal(SIGTSTP, SIG_DFL); + posix_kill(posix_getpid(), SIGTSTP); + break; + + case SIGCONT: + // Réenregistre le handler SIGTSTP après reprise + pcntl_signal(SIGTSTP, [$this, 'handleSignal']); + break; + } + } + + /** + * Appelle le gestionnaire personnalisé s'il est associé à ce signal. + * Se rabat sur onInterruption() si aucune association explicite n'existe. + */ + private function callCustomHandler(int $signal): void + { + // Association explicite en priorité + $method = $this->signalMethodMap[$signal] ?? null; + + if ($method !== null && method_exists($this, $method)) { + $this->{$method}($signal); + + return; + } + + // Si aucune association, tente la méthode générique onInterruption() + if (method_exists($this, 'onInterruption')) { // @phpstan-ignore-line + $this->onInterruption($signal); + } + } + + /** + * Indique si la commande doit s'arrêter. + */ + protected function shouldTerminate(): bool + { + return ! $this->running; + } + + /** + * Indique si le processus est encore en cours d'exécution. + */ + protected function isRunning(): bool + { + return $this->running; + } + + /** + * Demande l'arrêt immédiat. + */ + protected function requestTermination(): void + { + $this->running = false; + } + + /** + * Réinitialise tous les états (tests ou redémarrage). + */ + protected function resetState(): void + { + $this->running = true; + + // Débloque les signaux s'ils l'étaient + if ($this->signalsBlocked) { + $this->unblockSignals(); + } + } + + /** + * Exécute un callable en bloquant tous les signaux pour éviter toute interruption. + * + * Bloque tous les signaux interruptibles, notamment : + * - signaux de terminaison (SIGTERM, SIGINT, etc.) + * - pause / reprise (SIGTSTP, SIGCONT) + * - signaux personnalisés (SIGUSR1, SIGUSR2) + * + * Seul SIGKILL (non bloquable) peut encore terminer le processus. + * À utiliser pour les transactions SQL, les I/O fichiers ou toute opération atomique critique. + * + * @template TReturn + * + * @param Closure():TReturn $operation + * + * @return TReturn + */ + protected function withSignalsBlocked(Closure $operation) + { + $this->blockSignals(); + + try { + return $operation(); + } finally { + $this->unblockSignals(); + } + } + + /** + * Bloque tous les signaux interruptibles pendant une section critique. + * Seul SIGKILL (non bloquable) peut encore terminer le processus. + */ + protected function blockSignals(): void + { + if (! $this->signalsBlocked && $this->isPcntlAvailable()) { + // Bloque tous les signaux susceptibles d'interrompre une section critique + pcntl_sigprocmask(SIG_BLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Signaux de terminaison + SIGTSTP, SIGCONT, // Pause / reprise + SIGUSR1, SIGUSR2, // Signaux personnalisés + SIGPIPE, SIGALRM, // Autres signaux courants + ]); + $this->signalsBlocked = true; + } + } + + /** + * Débloque les signaux précédemment bloqués. + */ + protected function unblockSignals(): void + { + if ($this->signalsBlocked && $this->isPcntlAvailable()) { + // Débloque les mêmes signaux qu'on a bloqués + pcntl_sigprocmask(SIG_UNBLOCK, [ + SIGTERM, SIGINT, SIGHUP, SIGQUIT, // Signaux de terminaison + SIGTSTP, SIGCONT, // Pause / reprise + SIGUSR1, SIGUSR2, // Signaux personnalisés + SIGPIPE, SIGALRM, // Autres signaux courants + ]); + $this->signalsBlocked = false; + } + } + + /** + * Indique si les signaux sont actuellement bloqués. + */ + protected function signalsBlocked(): bool + { + return $this->signalsBlocked; + } + + /** + * Ajoute ou met à jour une association signal → méthode à l'exécution. + */ + protected function mapSignal(int $signal, string $method): void + { + $this->signalMethodMap[$signal] = $method; + } + + /** + * Retourne le nom lisible du signal. + */ + protected function getSignalName(int $signal): string + { + return match ($signal) { + SIGTERM => 'SIGTERM', + SIGINT => 'SIGINT', + SIGHUP => 'SIGHUP', + SIGQUIT => 'SIGQUIT', + SIGUSR1 => 'SIGUSR1', + SIGUSR2 => 'SIGUSR2', + SIGPIPE => 'SIGPIPE', + SIGALRM => 'SIGALRM', + SIGTSTP => 'SIGTSTP', + SIGCONT => 'SIGCONT', + default => "Signal {$signal}", + }; + } + + /** + * Désenregistre tous les signaux (nettoyage). + */ + protected function unregisterSignals(): void + { + if (! $this->isPcntlAvailable()) { + return; + } + + foreach ($this->registeredSignals as $signal) { + pcntl_signal($signal, SIG_DFL); + } + + $this->registeredSignals = []; + $this->signalMethodMap = []; + } + + /** + * Indique si des signaux sont enregistrés. + */ + protected function hasSignals(): bool + { + return $this->registeredSignals !== []; + } + + /** + * Retourne la liste des signaux enregistrés. + * + * @return list + */ + protected function getSignals(): array + { + return $this->registeredSignals; + } + + /** + * Retourne un état complet du processus. + * + * @return array{ + * pid: int, + * running: bool, + * pcntl_available: bool, + * registered_signals: int, + * registered_signals_names: array, + * signals_blocked: bool, + * explicit_mappings: int, + * memory_usage_mb: float, + * memory_peak_mb: float, + * session_id?: false|int, + * process_group?: false|int, + * has_controlling_terminal?: bool + * } + */ + protected function getProcessState(): array + { + $pid = getmypid(); + $state = [ + // Identification du processus + 'pid' => $pid, + 'running' => $this->running, + + // État de la gestion des signaux + 'pcntl_available' => $this->isPcntlAvailable(), + 'registered_signals' => count($this->registeredSignals), + 'registered_signals_names' => array_map([$this, 'getSignalName'], $this->registeredSignals), + 'signals_blocked' => $this->signalsBlocked, + 'explicit_mappings' => count($this->signalMethodMap), + + // Ressources système + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), + ]; + + // Infos de contrôle de terminal si l'extension POSIX est disponible + if ($this->isPosixAvailable()) { + $state['session_id'] = posix_getsid($pid); + $state['process_group'] = posix_getpgid($pid); + $state['has_controlling_terminal'] = posix_isatty(STDIN); + } + + return $state; + } + } +} diff --git a/src/Config/Services.php b/src/Config/Services.php new file mode 100644 index 0000000..9551cf1 --- /dev/null +++ b/src/Config/Services.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Config; + +use BlitzPHP\Container\Services as BaseServices; +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Queue\DTO\Config; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Failed\DatabaseFailedJobProvider; +use BlitzPHP\Queue\Failed\DatabaseUuidFailedJobProvider; +use BlitzPHP\Queue\Failed\FailedJobProviderInterface; +use BlitzPHP\Queue\Failed\FileFailedJobProvider; +use BlitzPHP\Queue\Failed\NullFailedJobProvider; +use BlitzPHP\Queue\Manager; +use BlitzPHP\Queue\Worker; + +/** + * Fabrique des services liés à la file d'attente (gestionnaire, worker, jobs échoués). + */ +class Services extends BaseServices +{ + /** + * Gestionnaire de files d'attente. + */ + public static function queue(array $config = [], bool $shared = true): Manager + { + if (true === $shared && isset(static::$instances[Manager::class])) { + return static::$instances[Manager::class]; + } + + $config = $config === [] ? config('queue', []) : $config; + + return static::$instances[Manager::class] = new Manager( + static::container(), + Config::fromArray($config), + ); + } + + /** + * Worker de file d'attente. + */ + public static function worker(bool $shared = true): Worker + { + if (true === $shared && isset(static::$instances[Worker::class])) { + return static::$instances[Worker::class]; + } + + $isDownForMaintenance = fn () => (bool) static::config()->get('app.maintenance.enable', false); + + $resetScope = function () { + $logger = static::logger(); + + if (method_exists($logger, 'flushSharedContext')) { + $logger->flushSharedContext(); + } + + if (method_exists($logger, 'withoutContext')) { + $logger->withoutContext(); + } + + $db = static::database(); + + if (method_exists($db, 'getConnections')) { + foreach ($db->getConnections() as $connection) { + // $connection->resetTotalQueryDuration(); + // $connection->allowQueryDurationHandlersToRunAgain(); + } + } + + memory_reset_peak_usage(); + }; + + return static::$instances[Worker::class] = new Worker( + static::queue(), + static::singleton(QueueEventManager::class), + $isDownForMaintenance, + $resetScope, + ); + } + + /** + * Fournisseur de jobs échoués selon `queue.failed.driver`. + */ + public static function queueFailer(array $config = [], bool $shared = true): FailedJobProviderInterface + { + if (true === $shared && isset(static::$instances[FailedJobProviderInterface::class])) { + return static::$instances[FailedJobProviderInterface::class]; + } + + $config = $config === [] ? static::config()->get('queue.failed', []) : $config; + $driver = $config['driver'] ?? 'null'; + + return static::$instances[FailedJobProviderInterface::class] = match ($driver) { + 'database' => new DatabaseFailedJobProvider( + static::singleton(ConnectionResolverInterface::class), + $config['database'] ?? 'default', + $config['table'] ?? 'queue_failed_jobs', + ), + 'database-uuids' => new DatabaseUuidFailedJobProvider( + static::singleton(ConnectionResolverInterface::class), + $config['database'] ?? 'default', + $config['table'] ?? 'queue_failed_jobs', + ), + 'file' => new FileFailedJobProvider( + $config['path'] ?? storage_path('logs/failed_jobs.json'), + $config['limit'] ?? 100, + ), + default => new NullFailedJobProvider(), + }; + } +} diff --git a/src/Config/queue.php b/src/Config/queue.php new file mode 100644 index 0000000..d32e57d --- /dev/null +++ b/src/Config/queue.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use BlitzPHP\Queue\Drivers\DatabaseDriver; + +/** + * Configuration du composant de files d'attente (queue). + * + * Ce fichier définit la connexion utilisée par défaut, les paramètres de chaque + * backend (base de données, Redis, Predis, RabbitMQ), le mapping des pilotes, + * ainsi que le stockage des jobs échoués et des lots (batching). + * + * Les valeurs peuvent être surchargées via les variables d'environnement + * correspondantes (préfixe `queue.`, `redis.`, `rabbitmq.`, `db.`). + */ +return [ + /** + * Nom de la connexion utilisée par défaut lorsque aucune n'est précisée + * à l'envoi ou au traitement d'un job (`queue:work`, `dispatch`, etc.). + * + * Doit correspondre à une clé de `connections` (ex. `database`, `redis`). + * Variable d'environnement : `queue.connection`. + */ + 'default' => env('queue.connection', 'database'), + + /** + * Définitions des connexions disponibles. + * + * Chaque entrée est identifiée par un nom (utilisé comme `driver` si la + * clé `driver` n'est pas fournie) et contient les options propres au backend. + */ + 'connections' => [ + /** + * File d'attente persistée en base de données. + * + * Les jobs sont stockés dans une table SQL, réservés par le worker + * (verrouillage de lignes) puis supprimés ou relâchés selon le résultat. + */ + 'database' => [ + /** + * Groupe / nom de connexion base de données BlitzPHP à utiliser + * pour lire et écrire les jobs. Variable : `queue.database.group`. + */ + 'group' => env('queue.database.group', 'default'), + + /** + * Si `true`, réutilise une connexion partagée du gestionnaire de + * connexions plutôt que d'en ouvrir une dédiée au worker. + */ + 'shared' => true, + + /** + * Si `true`, tente d'utiliser un verrouillage de type + * `SKIP LOCKED` / `READPAST` (selon le moteur) afin que plusieurs + * workers ne récupèrent pas le même job. + */ + 'skip_locked' => true, + + /** + * Nom de la table contenant les jobs en attente, retardés ou + * réservés. Variable : `queue.database.table`. + */ + 'table' => env('queue.database.table', 'queue_jobs'), + + /** + * Nom de la file logique par défaut pour cette connexion + * (colonne `queue` en base). Utilisé si `queue:work` n'en précise pas. + */ + // 'queue' => 'default', + + /** + * Délai en secondes au-delà duquel un job réservé est considéré + * comme expiré et peut être repris par un autre worker. + */ + // 'retry_after' => 60, + + /** + * Si `true`, n'envoie le job qu'après le commit des transactions + * de base de données en cours. + */ + // 'after_commit' => false, + ], + + /** + * Connexion Redis (extension PHP `redis` / PhpRedis). + * + * Les jobs sont poussés dans des listes Redis. Le pilote correspondant + * doit être enregistré dans `drivers` pour être utilisable. + */ + 'redis' => [ + /** + * Identifiant du pilote à instancier (`drivers.redis`). + */ + 'driver' => 'redis', + + /** + * Hôte du serveur Redis. Variable : `redis.host`. + */ + 'host' => env('redis.host', '127.0.0.1'), + + /** + * Mot de passe d'authentification Redis, ou `null` si aucun. + * Variable : `redis.password`. + */ + 'password' => env('redis.password', null), + + /** + * Port TCP du serveur Redis. Variable : `redis.port`. + */ + 'port' => env('redis.port', 6379), + + /** + * Index de la base Redis (0–15 en configuration par défaut). + * Variable : `redis.database`. + */ + 'database' => env('redis.database', 0), + ], + + /** + * Connexion Redis via Predis (client PHP pur, sans extension). + * + * Utile lorsque l'extension `redis` n'est pas disponible. + */ + 'predis' => [ + /** + * Identifiant du pilote à instancier (`drivers.predis`). + */ + 'driver' => 'predis', + + /** + * Schéma de connexion (`tcp`, `tls`, `unix`). + */ + 'scheme' => 'tcp', + + /** + * Hôte du serveur Redis. Variable : `redis.host`. + */ + 'host' => env('redis.host', '127.0.0.1'), + + /** + * Mot de passe d'authentification Redis, ou `null` si aucun. + * Variable : `redis.password`. + */ + 'password' => env('redis.password', null), + + /** + * Port TCP du serveur Redis. Variable : `redis.port`. + */ + 'port' => env('redis.port', 6379), + + /** + * Index de la base Redis. Variable : `redis.database`. + */ + 'database' => env('redis.database', 0), + ], + + /** + * Connexion RabbitMQ (AMQP). + * + * Les jobs sont publiés dans des files AMQP. Le pilote correspondant + * doit être enregistré dans `drivers` pour être utilisable. + */ + 'rabbitmq' => [ + /** + * Identifiant du pilote à instancier (`drivers.rabbitmq`). + */ + 'driver' => 'rabbitmq', + + /** + * Hôte du courtier RabbitMQ. Variable : `rabbitmq.host`. + */ + 'host' => env('rabbitmq.host', '127.0.0.1'), + + /** + * Port AMQP (5672 en clair, 5671 en TLS en général). + * Variable : `rabbitmq.port`. + */ + 'port' => env('rabbitmq.port', 5672), + + /** + * Nom d'utilisateur AMQP. Variable : `rabbitmq.user`. + */ + 'user' => env('rabbitmq.user', 'guest'), + + /** + * Mot de passe AMQP. Variable : `rabbitmq.password`. + */ + 'password' => env('rabbitmq.password', 'guest'), + + /** + * Hôte virtuel (vhost) isolant les files et échanges. + * Variable : `rabbitmq.vhost`. + */ + 'vhost' => env('rabbitmq.vhost', '/'), + ], + ], + + /** + * Correspondance entre le nom d'un pilote et sa classe PHP. + * + * La classe doit implémenter `ConnectorInterface` et exposer + * `connect(ContainerInterface $container, array $config)`. + * Les connexions dont le pilote n'est pas listé ici ne peuvent pas être résolues. + */ + 'drivers' => [ + /** + * Pilote SQL : table `queue_jobs` (ou celle configurée). + */ + 'database' => DatabaseDriver::class, + // 'redis' => \BlitzPHP\Queue\Drivers\Redis::class, + // 'predis' => \BlitzPHP\Queue\Drivers\Predis::class, + // 'rabbitmq' => \BlitzPHP\Queue\Drivers\RabbitMQ::class, + ], + + /** + * Si `true`, les jobs définitivement en échec sont conservés via le + * fournisseur configuré dans `failed` (base, fichier, etc.). + * Si `false`, l'échec est uniquement journalisé / ignoré selon le fournisseur. + */ + 'keep_failed_jobs' => true, + + /** + * Stockage des jobs échoués (après épuisement des tentatives ou échec manuel). + */ + 'failed' => [ + /** + * Fournisseur de persistance : + * - `database` : identifiants numériques auto-incrémentés + * - `database-uuids` : UUID du payload comme identifiant (recommandé) + * - `file` : fichier JSON (voir `path` / `limit` côté service) + * - `null` : aucun stockage + * + * Variable : `queue.failed_driver`. + */ + 'driver' => env('queue.failed_driver', 'database-uuids'), + + /** + * Nom de la connexion base de données utilisée pour la table des échecs + * (pilotes `database` et `database-uuids`). Variable : `db.connection`. + */ + 'database' => env('db.connection', 'default'), + + /** + * Table SQL des jobs échoués (`uuid`, `connection`, `queue`, `payload`, + * `exception`, `failed_at`). + */ + 'table' => 'queue_failed_jobs', + ], + + /** + * Stockage des lots de jobs (batching) : suivi d'un groupe de jobs + * dispatchés ensemble (progression, annulation, callbacks de fin). + */ + 'batching' => [ + /** + * Connexion base de données pour la table des lots. + * Variable : `db.connection`. + */ + 'database' => env('db.connection', 'default'), + + /** + * Nom de la table (ou identifiant de stockage) des lots de jobs. + */ + 'table' => 'queue.job_batches', + ], +]; diff --git a/src/DTO/Config.php b/src/DTO/Config.php new file mode 100644 index 0000000..4412748 --- /dev/null +++ b/src/DTO/Config.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\DTO; + +use BlitzPHP\Queue\Drivers\ConnectorInterface; +use InvalidArgumentException; + +/** + * Représentation objet de la configuration `queue.php`. + * + * Sert au gestionnaire pour résoudre la connexion par défaut, les options + * de chaque backend, les classes de pilotes et le stockage des échecs. + */ +class Config +{ + /** + * @param string $default Le nom de la connexion par défaut + * @param array> $connections Les configurations des connexions + * @param array> $drivers Les drivers disponibles + * @param bool $keep_failed_jobs Garder les jobs échoués + * @param array{driver: string, database: string, table: string} $failed Configuration des jobs échoués + * @param array{database: string, table: string} $batching Configuration du batching + * @param array $raw Données brutes supplémentaires + */ + public function __construct( + public string $default, + public array $connections = [], + public array $drivers = [], + public bool $keep_failed_jobs = true, + public array $failed = [], + public array $batching = [], + private array $raw = [], + ) { + } + + /** + * Crée une instance depuis la configuration automatique + */ + public static function auto(): self + { + return self::fromArray(config('queue', [])); + } + + /** + * Crée une instance depuis un tableau de configuration + */ + public static function fromArray(array $config): self + { + return new self( + default : $config['default'] ?? 'database', + connections : $config['connections'] ?? [], + drivers : $config['drivers'] ?? [], + keep_failed_jobs: $config['keep_failed_jobs'] ?? true, + failed : $config['failed'] ?? [], + batching : $config['batching'] ?? [], + raw : $config, + ); + } + + /** + * Convertit l'objet en tableau + */ + public function toArray(): array + { + return array_merge( + [ + 'default' => $this->default, + 'connections' => $this->connections, + 'drivers' => $this->drivers, + 'keep_failed_jobs' => $this->keep_failed_jobs, + 'failed' => $this->failed, + 'batching' => $this->batching, + ], + $this->raw, + ); + } + + /** + * Retourne la configuration d'une connexion, ou un pilote `null` si le nom est vide. + * + * @param string|null $name Nom de la connexion (`connections.{name}`). + * + * @return array + * + * @throws InvalidArgumentException Si la connexion n'est pas définie. + */ + public function connection(?string $name): array + { + if ($name === null || $name === 'null') { + return ['driver' => 'null']; + } + + if (! isset($this->connections[$name])) { + throw new InvalidArgumentException("The [{$name}] queue connection has not been configured."); + } + + return $this->connections[$name] + ['driver' => $name]; + } + + /** + * Retourne le nom de classe du pilote enregistré pour le nom donné. + * + * @param string $name Nom du pilote (ex. `database`). + * + * @return class-string + * + * @throws InvalidArgumentException Si le pilote n'est pas enregistré ou n'implémente pas le contrat. + */ + public function driver(string $name): string + { + $driver = $this->drivers[$name] ?? null; + + if ($driver === null) { + throw new InvalidArgumentException("Driver [{$name}] not registered."); + } + + if (! is_a($driver, ConnectorInterface::class, true)) { + throw new InvalidArgumentException(); + } + + return $driver; + } + + /** + * Définit le nom de la connexion de file d'attente par défaut. + * + * Met aussi à jour la configuration runtime `queue.default`. + */ + public function setDefaultDriver(string $name): void + { + $this->default = $name; + + config()->set('queue.default', $name); + } +} diff --git a/src/DTO/WorkerOptions.php b/src/DTO/WorkerOptions.php new file mode 100644 index 0000000..f7303b8 --- /dev/null +++ b/src/DTO/WorkerOptions.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\DTO; + +/** + * Options d'exécution d'un worker de file d'attente. + * + * Ces valeurs sont généralement renseignées par la commande `queue:work` + * et contrôlent la durée de vie, les limites et le comportement du processus. + */ +class WorkerOptions +{ + /** + * Crée une instance d'options du worker. + * + * @param string $name Nom du worker (utilisé pour les callbacks de pop personnalisés). + * @param int|list $backoff Secondes d'attente avant de relancer un job ayant levé une exception non gérée. + * @param int $memory Mémoire maximale autorisée (Mo) avant arrêt du worker. + * @param int $timeout Durée maximale d'exécution d'un job enfant (secondes). + * @param int $sleep Secondes d'attente entre deux sondages lorsque la file est vide. + * @param int $maxTries Nombre maximal de tentatives par job. + * @param bool $force Si `true`, le worker tourne même en mode maintenance. + * @param bool $stopWhenEmpty Si `true`, le worker s'arrête dès que la file est vide. + * @param int $maxJobs Nombre maximal de jobs à traiter (0 = illimité). + * @param int $maxTime Durée de vie maximale du worker en secondes (0 = illimitée). + * @param int $rest Secondes de pause entre deux jobs traités avec succès. + */ + public function __construct( + public string $name = 'default', + public array|int $backoff = 0, + public int $memory = 128, + public int $timeout = 60, + public int $sleep = 3, + public int $maxTries = 1, + public bool $force = false, + public bool $stopWhenEmpty = false, + public int $maxJobs = 0, + public int $maxTime = 0, + public $rest = 0, + ) { + } +} diff --git a/src/Database/Migrations/2026-08-26-061438_CreateQueueTables.php b/src/Database/Migrations/2026-08-26-061438_CreateQueueTables.php new file mode 100644 index 0000000..1a26031 --- /dev/null +++ b/src/Database/Migrations/2026-08-26-061438_CreateQueueTables.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Database\Migrations; + +use BlitzPHP\Database\Migration\Migration; +use BlitzPHP\Database\Migration\Structure; + +/** + * Crée les tables des jobs en file et des jobs échoués. + */ +class CreateQueueTables extends Migration +{ + /** + * Crée `queue_jobs` (ou table configurée) et `queue_failed_jobs`. + */ + public function up() + { + $this->create(config('queue.connections.database.table', 'queue_jobs'), function (Structure $table) { + $table->bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + + return $table; + }); + + $this->create(config('queue.failed.table', 'queue_failed_jobs'), function (Structure $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + return $table; + }); + } + + /** + * Supprime les tables de file et de jobs échoués. + */ + public function down() + { + $this->dropIfExists(config('queue.connections.database.table', 'queue_jobs')); + $this->dropIfExists(config('queue.failed.table', 'queue_failed_jobs')); + } +} diff --git a/src/Drivers/ConnectorInterface.php b/src/Drivers/ConnectorInterface.php new file mode 100644 index 0000000..01d62ce --- /dev/null +++ b/src/Drivers/ConnectorInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Drivers; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Queue; + +/** + * Contrat des pilotes de file : établit une connexion à partir de la configuration. + */ +interface ConnectorInterface +{ + /** + * Établit une connexion de file d'attente. + */ + public static function connect(ContainerInterface $container, array $config): Queue; +} diff --git a/src/Drivers/DatabaseDriver.php b/src/Drivers/DatabaseDriver.php new file mode 100644 index 0000000..c79172c --- /dev/null +++ b/src/Drivers/DatabaseDriver.php @@ -0,0 +1,417 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Drivers; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Database\ConnectionInterface; +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Exceptions\CriticalError; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Jobs\DatabaseJob; +use BlitzPHP\Queue\Jobs\DatabaseJobRecord; +use BlitzPHP\Queue\Jobs\InspectedJob; +use BlitzPHP\Queue\Models\JobModel; +use BlitzPHP\Queue\Queue; +use BlitzPHP\Utilities\Iterable\Collection; +use BlitzPHP\Utilities\String\Stringable; +use BlitzPHP\Utilities\String\Text; +use DateInterval; +use DateTimeInterface; +use Throwable; + +/** + * Pilote de file d'attente persisté en base de données. + */ +class DatabaseDriver extends Queue implements QueueContract, ConnectorInterface +{ + /** + * Type de verrou mis en cache pour le prélèvement des jobs. + * + * @var bool|string|null + */ + protected $lockForPopping; + + /** + * Crée une instance de file d'attente base de données. + * + * @param string $default Nom de la file par défaut. + */ + public function __construct(protected JobModel $model, protected string $default = 'default', bool $dispatchAfterCommit = false) + { + $this->dispatchAfterCommit = $dispatchAfterCommit; + } + + /** + * Établit une connexion de file d'attente. + * + * @param array $config Configuration de la connexion. + */ + public static function connect(ContainerInterface $container, array $config): QueueContract + { + try { + $connection = service('database', $config['connection'] ?? null, $config['shared'] ?? true); + + $queue = new self( + new JobModel( + $config, + $container->get(ConnectionResolverInterface::class), + $connection, + ), + $config['queue'], + $config['after_commit'] ?? false, + ); + + $container->get(QueueEventManager::class)->handlerConnectionEstablished( + connection: $queue->getConnectionName(), + config: $config, + ); + + return $queue; + } catch (Throwable $e) { + $container->get(QueueEventManager::class)->handlerConnectionFailed( + connection: 'default', + config: $config, + exception: $e, + ); + + throw new CriticalError('Queue: Database connection failed. ' . $e->getMessage()); + } + } + + /** + * Retourne le nombre total de jobs dans la file. + */ + public function size(?string $queue = null): int + { + return $this->model->size($this->getQueue($queue)); + } + + /** + * Retourne le nombre de jobs en attente. + */ + public function pendingSize(?string $queue = null): int + { + return $this->model->pendingSize($this->getQueue($queue)); + } + + /** + * Retourne le nombre de jobs retardés. + */ + public function delayedSize(?string $queue = null): int + { + return $this->model->delayedSize($this->getQueue($queue)); + } + + /** + * Retourne le nombre de jobs réservés. + */ + public function reservedSize(?string $queue = null): int + { + return $this->model->reservedSize($this->getQueue($queue)); + } + + /** + * Retourne les jobs en attente de la file donnée. + * + * @return Collection + */ + public function pendingJobs(?string $queue = null): Collection + { + return collect($this->model->pendingJobs($this->getQueue($queue))) + ->map(fn ($record) => InspectedJob::fromPayload($record->payload, $record->attempts)); + } + + /** + * Retourne les jobs retardés de la file donnée. + * + * @return Collection + */ + public function delayedJobs(?string $queue = null): Collection + { + return collect($this->model->delayedJobs($this->getQueue($queue))) + ->map(fn ($record) => InspectedJob::fromPayload($record->payload, $record->attempts)); + } + + /** + * Retourne les jobs réservés de la file donnée. + * + * @return Collection + */ + public function reservedJobs(?string $queue = null): Collection + { + return collect($this->model->reservedJobs($this->getQueue($queue))) + ->map(fn ($record) => InspectedJob::fromPayload($record->payload, $record->attempts)); + } + + /** + * Retourne l'horodatage de création du plus ancien job en attente (hors retardés). + */ + public function creationTimeOfOldestPendingJob(?string $queue = null): ?int + { + return $this->model->creationTimeOfOldestPendingJob($this->getQueue($queue)); + } + + /** + * Envoie un nouveau job dans la file. + */ + public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data), + $queue, + null, + fn ($payload, $queue) => $this->pushToDatabase($queue, $payload), + ); + } + + /** + * Envoie un payload brut dans la file. + */ + public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed + { + return $this->pushToDatabase($queue, $payload); + } + + /** + * Envoie un job dans la file après n secondes. + */ + public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return $this->enqueueUsing( + $job, + $this->createPayload($job, $this->getQueue($queue), $data, $delay), + $queue, + $delay, + fn ($payload, $queue, $delay) => $this->pushToDatabase($queue, $payload, $delay), + ); + } + + /** + * Envoie un tableau de jobs dans la file. + */ + public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixed + { + $queue = $this->getQueue($queue); + + $now = $this->availableAt(); + + $this->model->insert((new Collection((array) $jobs))->map( + fn ($job) => $this->buildDatabaseRecord( + $queue, + $this->createPayload($job, $this->getQueue($queue), $data), + isset($job->delay) ? $this->availableAt($job->delay) : $now, + ), + )->all()); + + return null; + } + + /** + * Relâche un job réservé dans la file après n secondes. + */ + public function release(string $queue, DatabaseJobRecord $job, int $delay): mixed + { + return $this->pushToDatabase($queue, $job->payload, $delay, $job->attempts); + } + + /** + * Insère un payload brut en base avec un délai de n secondes. + */ + protected function pushToDatabase(?string $queue, string $payload, DateInterval|DateTimeInterface|int $delay = 0, int $attempts = 0): mixed + { + return $this->model->pushToDatabase($this->buildDatabaseRecord( + $this->getQueue($queue), + $payload, + $this->availableAt($delay), + $attempts, + )); + } + + /** + * Construit le tableau à insérer pour le job donné. + */ + protected function buildDatabaseRecord(?string $queue, string $payload, int $availableAt, int $attempts = 0): array + { + return [ + 'queue' => $queue, + 'attempts' => $attempts, + 'reserved_at' => null, + 'available_at' => $availableAt, + 'created_at' => $this->currentTime(), + 'payload' => $payload, + ]; + } + + /** + * Prélève le prochain job de la file. + * + * @throws Throwable + */ + public function pop(?string $queue = null): ?Job + { + $queue = $this->getQueue($queue); + + $jobRecord = null; + + try { + return $this->model->transaction(function () use ($queue, &$jobRecord) { + if ($jobRecord = $this->getNextAvailableJob($queue)) { + return $this->marshalJob($queue, $jobRecord); + } + }); + } catch (Throwable $e) { + // Job potentiellement invalide : on tente de le marquer en échec. + if ($jobRecord) { + try { + (new DatabaseJob( + $this->container, + $this, + $jobRecord, + $this->connectionName, + $queue, + ))->fail($e); + } catch (Throwable) { + // Ignore et relance l'exception d'origine. + } + } + + throw $e; + } + } + + /** + * Retourne le prochain job disponible de la file. + */ + protected function getNextAvailableJob(?string $queue): ?DatabaseJobRecord + { + $job = $this->model->getNextAvailableJob($this->getQueue($queue)); + + return $job ? new DatabaseJobRecord((object) $job) : null; + } + + /** + * Retourne le verrou SQL nécessaire pour prélever le prochain job. + * + * @return bool|string + */ + protected function getLockForPopping() + { + if ($this->lockForPopping !== null) { + return $this->lockForPopping; + } + + $databaseEngine = $this->model->db()->getPlatform(); + $databaseVersion = $this->model->db()->getVersion(); + + if ((new Stringable($databaseVersion))->contains('MariaDB')) { + $databaseEngine = 'mariadb'; + $databaseVersion = Text::before(Text::after($databaseVersion, '5.5.5-'), '-'); + } elseif ((new Stringable($databaseVersion))->contains(['vitess', 'PlanetScale'])) { + $databaseEngine = 'vitess'; + $databaseVersion = Text::before($databaseVersion, '-'); + } + + if (($databaseEngine === 'mysql' && version_compare($databaseVersion, '8.0.1', '>=')) + || ($databaseEngine === 'mariadb' && version_compare($databaseVersion, '10.6.0', '>=')) + || ($databaseEngine === 'pgsql' && version_compare($databaseVersion, '9.5', '>=')) + || ($databaseEngine === 'vitess' && version_compare($databaseVersion, '19.0', '>=')) + ) { + return $this->lockForPopping = 'FOR UPDATE SKIP LOCKED'; + } + + if ($databaseEngine === 'sqlsrv') { + return $this->lockForPopping = 'with(rowlock,updlock,readpast)'; + } + + return $this->lockForPopping = true; + } + + /** + * Transforme le job réservé en instance DatabaseJob. + */ + protected function marshalJob(string $queue, DatabaseJobRecord $job): DatabaseJob + { + return new DatabaseJob( + $this->container, + $this, + $this->markJobAsReserved($job), + $this->connectionName, + $queue, + ); + } + + /** + * Marque le job comme réservé. + */ + protected function markJobAsReserved(DatabaseJobRecord $job): DatabaseJobRecord + { + $this->model->where('id', $job->id)->update([ + 'reserved_at' => $job->touch(), + 'attempts' => $job->increment(), + ]); + + return $job; + } + + /** + * Supprime un job réservé de la file. + * + * @throws Throwable + */ + public function deleteReserved(string $queue, string $id): void + { + $this->model->deleteReserved($queue, $id); + } + + /** + * Supprime le job réservé puis le relâche dans la file. + */ + public function deleteAndRelease(string $queue, DatabaseJob $job, int $delay): void + { + $this->model->transaction(function () use ($queue, $job, $delay) { + $where = ['id' => $job->getJobId()]; + + if ($this->model/* ->lockForUpdate() */ ->where($where)->first()) { + $this->model->where($where)->delete(); + } + + $this->release($queue, $job->getJobRecord(), $delay); + }); + } + + /** + * Supprime tous les jobs de la file. + */ + public function clear(string $queue): bool + { + return $this->model->clear($this->getQueue($queue)); + } + + /** + * Retourne le nom de file, ou la file par défaut. + */ + public function getQueue(?string $queue): string + { + return $queue ?: $this->default; + } + + /** + * Retourne l'instance de connexion base de données. + */ + public function getDatabase(): ConnectionInterface + { + return $this->model->db(); + } +} diff --git a/src/Drivers/FailoverDriver.php b/src/Drivers/FailoverDriver.php new file mode 100644 index 0000000..3b5048f --- /dev/null +++ b/src/Drivers/FailoverDriver.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Drivers; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Manager; +use BlitzPHP\Queue\Queue; +use BlitzPHP\Utilities\Iterable\Collection; +use DateInterval; +use DateTimeInterface; +use RuntimeException; +use Throwable; + +/** + * Pilote de bascule : tente successivement plusieurs connexions en cas d'échec. + */ +class FailoverDriver extends Queue implements QueueContract, ConnectorInterface +{ + /** + * Connexions ayant échoué lors de la dernière opération. + * + * @var list + */ + protected array $failingQueues = []; + + /** + * Crée une instance de file en bascule (failover). + */ + public function __construct(public Manager $manager, public QueueEventManager $events, public array $connections) + { + } + + /** + * Établit une connexion de file d'attente. + */ + public static function connect(ContainerInterface $container, array $config): QueueContract + { + return new self( + $container->make(Manager::class), + $container->make(QueueEventManager::class), + $config['connections'], + ); + } + + /** + * Retourne le nombre total de jobs dans la file. + */ + public function size(?string $queue = null): int + { + return $this->manager->connection($this->connections[0])->size($queue); + } + + /** + * Retourne le nombre de jobs en attente. + */ + public function pendingSize(?string $queue = null): int + { + return $this->manager->connection($this->connections[0])->pendingSize($queue); + } + + /** + * Retourne le nombre de jobs retardés. + */ + public function delayedSize(?string $queue = null): int + { + return $this->manager->connection($this->connections[0])->delayedSize($queue); + } + + /** + * Retourne le nombre de jobs réservés. + */ + public function reservedSize(?string $queue = null): int + { + return $this->manager->connection($this->connections[0])->reservedSize($queue); + } + + /** + * Retourne les jobs en attente de la file donnée. + */ + public function pendingJobs(?string $queue = null): Collection + { + return $this->manager->connection($this->connections[0])->pendingJobs($queue); + } + + /** + * Retourne les jobs retardés de la file donnée. + */ + public function delayedJobs(?string $queue = null): Collection + { + return $this->manager->connection($this->connections[0])->delayedJobs($queue); + } + + /** + * Retourne les jobs réservés de la file donnée. + */ + public function reservedJobs(?string $queue = null): Collection + { + return $this->manager->connection($this->connections[0])->reservedJobs($queue); + } + + /** + * Retourne l'horodatage de création du plus ancien job en attente (hors retardés). + */ + public function creationTimeOfOldestPendingJob(?string $queue = null): ?int + { + return $this->manager + ->connection($this->connections[0]) + ->creationTimeOfOldestPendingJob($queue); + } + + /** + * Envoie un nouveau job dans la file. + */ + public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); + } + + /** + * Envoie un payload brut dans la file. + */ + public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed + { + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args()); + } + + /** + * Envoie un job dans la file après n secondes. + */ + public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); + } + + /** + * Prélève le prochain job de la file. + */ + public function pop(?string $queue = null): ?Job + { + return $this->manager->connection($this->connections[0])->pop($queue); + } + + /** + * Tente la méthode donnée sur toutes les connexions, dans l'ordre. + * + * @throws Throwable + */ + protected function attemptOnAllConnections(string $method, array $arguments, ?string $job = null): mixed + { + [$lastException, $failedQueues] = [null, []]; + + try { + foreach ($this->connections as $connection) { + try { + return $this->manager->connection($connection)->{$method}(...$arguments); + } catch (Throwable $e) { + $lastException = $e; + + $failedQueues[] = $connection; + + if ($job !== null && ! in_array($connection, $this->failingQueues, true)) { + $this->events->queueFailedOver($connection, $job, $e); + } + } + } + } finally { + $this->failingQueues = $failedQueues; + } + + throw $lastException ?? new RuntimeException('All failover queue connections failed.'); + } +} diff --git a/src/Drivers/NullDriver.php b/src/Drivers/NullDriver.php new file mode 100644 index 0000000..872a17d --- /dev/null +++ b/src/Drivers/NullDriver.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Drivers; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Queue\Queue; +use BlitzPHP\Utilities\Iterable\Collection; +use DateInterval; +use DateTimeInterface; + +/** + * Pilote nul : accepte les jobs sans les stocker ni les exécuter. + */ +class NullDriver extends Queue implements QueueContract, ConnectorInterface +{ + /** + * Établit une connexion de file d'attente. + */ + public static function connect(ContainerInterface $container, array $config): QueueContract + { + return new self(); + } + + /** + * Retourne le nombre total de jobs dans la file. + */ + public function size(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs en attente. + */ + public function pendingSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs retardés. + */ + public function delayedSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs réservés. + */ + public function reservedSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne les jobs en attente de la file donnée. + */ + public function pendingJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne les jobs retardés de la file donnée. + */ + public function delayedJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne les jobs réservés de la file donnée. + */ + public function reservedJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne l'horodatage de création du plus ancien job en attente (hors retardés). + */ + public function creationTimeOfOldestPendingJob(?string $queue = null): ?int + { + return null; + } + + /** + * Envoie un nouveau job dans la file. + */ + public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return null; + } + + /** + * Envoie un payload brut dans la file. + */ + public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed + { + return null; + } + + /** + * Envoie un job dans la file après n secondes. + */ + public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return null; + } + + /** + * Prélève le prochain job de la file. + */ + public function pop(?string $queue = null): ?Job + { + return null; + } +} diff --git a/src/Drivers/SyncDriver.php b/src/Drivers/SyncDriver.php new file mode 100644 index 0000000..f2a807f --- /dev/null +++ b/src/Drivers/SyncDriver.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Drivers; + +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Queue\Jobs\SyncJob; +use BlitzPHP\Queue\Queue; +use BlitzPHP\Utilities\Iterable\Collection; +use DateInterval; +use DateTimeInterface; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * Pilote synchrone : exécute le job immédiatement dans le processus courant. + */ +class SyncDriver extends Queue implements QueueContract, ConnectorInterface +{ + /** + * Crée une instance de file synchrone. + */ + public function __construct(bool $dispatchAfterCommit = false) + { + $this->dispatchAfterCommit = $dispatchAfterCommit; + } + + /** + * Établit une connexion de file d'attente. + */ + public static function connect(ContainerInterface $container, array $config): QueueContract + { + return new self($config['after_commit'] ?? null); + } + + /** + * Retourne le nombre total de jobs dans la file. + */ + public function size(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs en attente. + */ + public function pendingSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs retardés. + */ + public function delayedSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne le nombre de jobs réservés. + */ + public function reservedSize(?string $queue = null): int + { + return 0; + } + + /** + * Retourne les jobs en attente de la file donnée. + */ + public function pendingJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne les jobs retardés de la file donnée. + */ + public function delayedJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne les jobs réservés de la file donnée. + */ + public function reservedJobs(?string $queue = null): Collection + { + return new Collection(); + } + + /** + * Retourne l'horodatage de création du plus ancien job en attente (hors retardés). + */ + public function creationTimeOfOldestPendingJob(?string $queue = null): ?int + { + return null; + } + + /** + * Envoie un nouveau job dans la file. + * + * @throws Throwable + */ + public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed + { + $job = $job instanceof Job ? $job->getJobId() : (string) $job; + + /* + if ($this->shouldDispatchAfterCommit($job) && + $this->container->bound('db.transactions')) { + if ($job instanceof ShouldBeUnique) { + $this->container->make('db.transactions')->addCallbackForRollback( + function () use ($job) { + (new UniqueLock($this->container->make(Cache::class)))->release($job); + } + ); + } + + return $this->container->make('db.transactions')->addCallback( + fn () => $this->executeJob($job, $data, $queue) + ); + } + */ + + return $this->executeJob($job, $data, $queue); + } + + /** + * Exécute un job de façon synchrone. + * + * @throws Throwable + */ + protected function executeJob(string $job, mixed $data = '', ?string $queue = null): int + { + $queueJob = $this->resolveJob($this->createPayload($job, $queue, $data), $queue); + + try { + $this->raiseBeforeJobEvent($queueJob); + + $queueJob->fire(); + + $this->raiseAfterJobEvent($queueJob); + } catch (Throwable $e) { + $exceptionOccurred = $e; + + $this->handleException($queueJob, $e); + } finally { + $this->raiseJobAttemptedEvent($queueJob, $exceptionOccurred ?? null); + } + + return 0; + } + + /** + * Résout une instance de job synchrone. + */ + protected function resolveJob(string $payload, string $queue): SyncJob + { + return new SyncJob($this->container, $payload, $this->connectionName, $queue); + } + + /** + * Émet l'événement avant traitement du job. + */ + protected function raiseBeforeJobEvent(Job $job): void + { + $this->eventManager()->jobProcessing($this->connectionName, $job); + } + + /** + * Émet l'événement après traitement du job. + */ + protected function raiseAfterJobEvent(Job $job): void + { + $this->eventManager()->jobProcessed($this->connectionName, $job); + } + + /** + * Émet l'événement de tentative de job. + */ + protected function raiseJobAttemptedEvent(Job $job, ?Throwable $exceptionOccurred = null): void + { + $this->eventManager()->jobAttempted($this->connectionName, $job, $exceptionOccurred); + } + + /** + * Émet l'événement d'exception survenue sur un job. + */ + protected function raiseExceptionOccurredJobEvent(Job $job, Throwable $e): void + { + $this->eventManager()->jobExceptionOccured($this->connectionName, $job, $e); + } + + /** + * Traite une exception survenue pendant le traitement d'un job. + * + * @throws Throwable + */ + protected function handleException(Job $queueJob, Throwable $e): void + { + $this->raiseExceptionOccurredJobEvent($queueJob, $e); + + $queueJob->fail($e); + + throw $e; + } + + /** + * Envoie un payload brut dans la file. + */ + public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed + { + return null; + } + + /** + * Envoie un job dans la file après n secondes. + */ + public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed + { + return $this->push($job, $data, $queue); + } + + /** + * Prélève le prochain job de la file. + */ + public function pop(?string $queue = null): ?Job + { + return null; + } +} diff --git a/src/Enums/WorkerStopReason.php b/src/Enums/WorkerStopReason.php new file mode 100644 index 0000000..7ce2439 --- /dev/null +++ b/src/Enums/WorkerStopReason.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Enums; + +/** + * Motifs d'arrêt d'un worker de file d'attente. + */ +enum WorkerStopReason: string +{ + /** + * Interruption par signal (SIGINT, SIGTERM, etc.). + */ + case Interrupted = 'interrupted'; + /** + * Perte de connexion (base de données, courtier, etc.). + */ + case LostConnection = 'lost_connection'; + /** + * Nombre maximal de jobs atteint. + */ + case MaxJobsExceeded = 'max_jobs'; + /** + * Limite mémoire dépassée. + */ + case MaxMemoryExceeded = 'memory'; + /** + * Durée de vie maximale du worker atteinte. + */ + case MaxTimeExceeded = 'max_time'; + /** + * File vide et option `stopWhenEmpty` active. + */ + case QueueEmpty = 'empty'; + /** + * Signal de redémarrage reçu via le cache. + */ + case ReceivedRestartSignal = 'restart_signal'; + /** + * Dépassement du délai d'exécution d'un job. + */ + case TimedOut = 'timed_out'; +} diff --git a/src/Events/QueueEvent.php b/src/Events/QueueEvent.php new file mode 100644 index 0000000..5f1f044 --- /dev/null +++ b/src/Events/QueueEvent.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Events; + +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Event\Event; +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\String\Text; +use Throwable; + +/** + * Événement du cycle de vie de la file d'attente (job, worker, connexion, opération). + * + * @property ?int $attempts + * @property ?Throwable $exception + * @property mixed $job + * @property ?int $jobId + */ +class QueueEvent extends Event +{ + /** + * Instant de survenue de l'événement. + */ + private readonly Date $timestamp; + + /** + * @param string $type Identifiant de l'événement (constantes de QueueEventManager). + * @param string $connection Nom de la connexion concernée. + * @param string|null $queue Nom de la file, le cas échéant. + * @param array $metadata Données contextuelles (job, exception, etc.). + * @param Date|null $timestamp Horodatage (maintenant par défaut). + */ + public function __construct( + public readonly string $type, + public readonly string $connection, + public readonly ?string $queue = null, + private readonly array $metadata = [], + ?Date $timestamp = null, + ) { + parent::__construct($this->type); + + $this->timestamp = $timestamp ?? Date::now(); + } + + /** + * Retourne l'horodatage de l'événement. + */ + public function timestamp(): Date + { + return $this->timestamp; + } + + /** + * Retourne l'ensemble des métadonnées. + */ + public function allMetadata(): array + { + return $this->metadata; + } + + /** + * Retourne une métadonnée par sa clé. + */ + public function metadata(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } + + /** + * Indique s'il s'agit d'un événement lié à un job. + */ + public function isJobEvent(): bool + { + return str_starts_with($this->type, 'queue.job.'); + } + + /** + * Indique s'il s'agit d'un événement lié au worker. + */ + public function isWorkerEvent(): bool + { + return str_starts_with($this->type, 'queue.worker.'); + } + + /** + * Indique s'il s'agit d'un événement d'opération (ex. file vidée). + */ + public function isOperationEvent(): bool + { + return str_contains($this->type, 'queue.') + && ! $this->isJobEvent() + && ! $this->isWorkerEvent() + && ! $this->isConnectionEvent(); + } + + /** + * Indique s'il s'agit d'un événement de connexion. + */ + public function isConnectionEvent(): bool + { + return str_starts_with($this->type, 'queue.connection.'); + } + + /** + * Retourne l'identifiant du job (événements de job). + */ + public function getJobId(): ?int + { + $job = $this->job; + + return $job instanceof Job ? $job->getJobId() : $this->metadata('job_id'); + } + + /** + * Retourne le nombre de tentatives (événements de job). + */ + public function getAttempts(): ?int + { + $job = $this->job; + + return $job instanceof Job ? $job->attempts() : $this->metadata('attempts'); + } + + /** + * Retourne le statut du job (événements de job). + */ + public function getStatus(): ?int + { + $job = $this->job; + + return $job instanceof Job ? $job->status : $this->metadata('status'); + } + + /** + * Retourne le nom de classe du job (événements de job). + */ + public function getJobClass(): ?string + { + return $this->metadata('job_class'); + } + + /** + * Retourne le temps de traitement en secondes. + */ + public function getProcessingTime(): float + { + return (float) $this->metadata('processing_time', 0.0); + } + + /** + * Retourne le temps de traitement en millisecondes. + */ + public function getProcessingTimeMs(): int + { + return (int) ($this->getProcessingTime() * 1000); + } + + /** + * Retourne l'exception (événements d'échec). + */ + public function getException(): ?Throwable + { + return $this->metadata('exception') ?? $this->metadata('e'); + } + + /** + * Retourne le message d'exception (événements d'échec). + */ + public function getExceptionMessage(): ?string + { + return $this->getException()?->getMessage(); + } + + /** + * Indique si l'événement correspond à un échec. + */ + public function hasFailed(): bool + { + $job = $this->job; + + return $job instanceof Job ? $job->hasFailed() : $this->getException() !== null; + } + + /** + * Convertit l'événement en tableau pour sérialisation. + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'connection' => $this->connection, + 'queue' => $this->queue, + 'metadata' => $this->metadata, + 'timestamp' => $this->timestamp->toDateTimeString(), + ]; + } + + /** + * Accès magique aux métadonnées et accesseurs `get*`. + */ + public function __get(string $name): mixed + { + if (method_exists($this, $method = 'get' . Text::camel($name))) { + return $this->{$method}(); + } + + return $this->metadata($name); + } +} diff --git a/src/Events/QueueEventManager.php b/src/Events/QueueEventManager.php new file mode 100644 index 0000000..240116d --- /dev/null +++ b/src/Events/QueueEventManager.php @@ -0,0 +1,304 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Events; + +use BlitzPHP\Contracts\Event\EventManagerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Queue\DTO\WorkerOptions; +use BlitzPHP\Queue\Enums\WorkerStopReason; +use DateInterval; +use DateTimeInterface; +use Throwable; + +/** + * Émet les événements du cycle de vie des files, jobs et workers. + */ +class QueueEventManager +{ + /** + * Noms d'événements des opérations de file. + */ + public const JOB_POPPING = 'queue.job.popping'; + + public const JOB_POPPED = 'queue.job.popped'; + public const JOB_PUSHED = 'queue.job.pushed'; + public const JOB_PUSH_FAILED = 'queue.job.push.failed'; + public const JOB_PROCESSING = 'queue.job.processing'; + public const JOB_PROCESSED = 'queue.job.processed'; + public const JOB_PROCESSING_COMPLETED = 'queue.job.processing.completed'; + public const JOB_EXCEPTION_OCCURED = 'queue.job.exception-occured'; + public const JOB_ATTEMPTED = 'queue.job.attempted'; + public const JOB_FAILED = 'queue.job.failed'; + public const JOB_LOOPING = 'queue.job.looping'; + public const JOB_RELEASED_AFTER_EXCEPTION = 'queue.job.release-after-exception'; + public const JOB_TIMEOUT = 'queue.job.timeout'; + public const JOB_QUEUED = 'queue.job.queued'; + public const JOB_QUEUEING = 'queue.job.queuing'; + public const QUEUE_CLEARED = 'queue.cleared'; + public const QUEUE_PAUSED = 'queue.paused'; + public const QUEUE_RESUMED = 'queue.resumed'; + public const QUEUE_FAILED_OVER = 'queue.failed-over'; + public const WORKER_STARTING = 'queue.worker.starting'; + public const WORKER_STOPPING = 'queue.worker.stopping'; + public const HANDLER_CONNECTION_FAILED = 'queue.handler.connection.failed'; + public const HANDLER_CONNECTION_ESTABLISHED = 'queue.handler.connection.established'; + + /** + * @param EventManagerInterface $events Gestionnaire d'événements de l'application. + */ + public function __construct(protected EventManagerInterface $events) + { + } + + /** + * Émet l'événement de tentative de job. + */ + public function jobAttempted(string $connection, Job $job, ?Throwable $e = null): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_ATTEMPTED, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job', 'e'), + )); + } + + /** + * Émet l'événement d'échec de job. + */ + public function jobFailed(string $connection, Job $job, ?Throwable $e): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_FAILED, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job', 'e'), + )); + } + + /** + * Émet l'événement d'exception survenue sur un job. + */ + public function jobExceptionOccured(string $connection, Job $job, Throwable $e): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_EXCEPTION_OCCURED, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job', 'e'), + )); + } + + /** + * Émet l'événement de prélèvement imminent d'un job. + */ + public function jobPopping(string $connection, ?string $queue = null): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_POPPING, + connection: $connection, + queue : $queue, + )); + } + + /** + * Émet l'événement de job prélevé. + */ + public function jobPopped(string $connection, ?Job $job = null): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_POPPED, + connection: $connection, + queue : $job?->getQueue(), + metadata : compact('job'), + )); + } + + /** + * Émet l'événement de traitement en cours. + */ + public function jobProcessing(string $connection, Job $job): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_PROCESSING, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job'), + )); + } + + /** + * Émet l'événement de job traité. + */ + public function jobProcessed(string $connection, Job $job): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_PROCESSED, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job'), + )); + } + + /** + * Émet l'événement « job enfilé ». + */ + public function jobQueued(string $connection, ?string $queue, int|string|null $jobId, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_QUEUED, + connection: $connection, + queue : $queue, + metadata : compact('jobId', 'job', 'payload', 'delay'), + )); + } + + /** + * Émet l'événement « job en cours d'enfilement ». + */ + public function jobQueueing(string $connection, ?string $queue, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_QUEUEING, + connection: $connection, + queue : $queue, + metadata : compact('job', 'payload', 'delay'), + )); + } + + /** + * Émet l'événement de relâchement après exception. + */ + public function jobReleasedAfterException(string $connection, Job $job, int $backoff): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_RELEASED_AFTER_EXCEPTION, + connection: $connection, + queue : $job->getQueue(), + metadata : compact('job', 'backoff'), + )); + } + + /** + * Émet l'événement de dépassement de délai. + */ + public function jobTimeout(string $connection, string $queue, Job $job, array $metadata = []): void + { + $this->events->emit(new QueueEvent( + type : self::JOB_TIMEOUT, + connection: $connection, + queue : $queue, + metadata : array_merge([ + 'job_class' => $job->payload['job'], + 'job' => $job, + ], $metadata), + )); + } + + /** + * Émet l'événement de file vidée. + */ + public function queueCleared(string $connection, ?string $queue = null): void + { + $this->events->emit(new QueueEvent( + type : self::QUEUE_CLEARED, + connection: $connection, + queue : $queue, + )); + } + + /** + * Émet l'événement de file en pause. + */ + public function queuePaused(string $connection, string $queue, DateInterval|DateTimeInterface|int|null $ttl = null): void + { + $this->events->emit(new QueueEvent( + type : self::QUEUE_PAUSED, + connection: $connection, + queue : $queue, + metadata : compact('ttl'), + )); + } + + /** + * Émet l'événement de reprise de file. + */ + public function queueResumed(string $connection, string $queue): void + { + $this->events->emit(new QueueEvent( + type : self::QUEUE_RESUMED, + connection: $connection, + queue : $queue, + )); + } + + /** + * Émet l'événement de bascule (failover) vers une autre connexion. + */ + public function queueFailedOver(string $connection, string $job, Throwable $e): void + { + $this->events->emit(new QueueEvent( + type : self::QUEUE_FAILED_OVER, + connection: $connection, + metadata : compact('job', 'e'), + )); + } + + /** + * Émet l'événement de démarrage du worker. + */ + public function workerStarting(string $connection, string $queue, WorkerOptions $options): void + { + $this->events->emit(new QueueEvent( + type : self::WORKER_STARTING, + connection: $connection, + queue : $queue, + metadata : compact('options'), + )); + } + + /** + * Émet l'événement d'arrêt du worker. + */ + public function workerStopping(string $connection, int $status, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null): void + { + $this->events->emit(new QueueEvent( + type : self::WORKER_STOPPING, + connection: $connection, + metadata : compact('status', 'options', 'reason'), + )); + } + + /** + * Émet l'événement de connexion de pilote établie. + */ + public function handlerConnectionEstablished(string $connection, array $config = []): void + { + $this->events->emit(new QueueEvent( + type : self::HANDLER_CONNECTION_ESTABLISHED, + connection: $connection, + metadata : compact('config'), + )); + } + + /** + * Émet l'événement d'échec de connexion de pilote. + */ + public function handlerConnectionFailed(string $connection, Throwable $exception, array $config = []): void + { + $this->events->emit(new QueueEvent( + type : self::HANDLER_CONNECTION_FAILED, + connection: $connection, + metadata : compact('config', 'exception'), + )); + } +} diff --git a/src/Exceptions/InvalidPayloadException.php b/src/Exceptions/InvalidPayloadException.php new file mode 100644 index 0000000..4ebc64f --- /dev/null +++ b/src/Exceptions/InvalidPayloadException.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Exceptions; + +use InvalidArgumentException; + +/** + * Exception levée lorsque le payload d'un job ne peut pas être encodé en JSON. + */ +class InvalidPayloadException extends InvalidArgumentException +{ + /** + * Valeur dont le décodage / l'encodage a échoué. + */ + public mixed $value; + + /** + * Crée une nouvelle instance d'exception. + */ + public function __construct(?string $message = null, mixed $value = null) + { + parent::__construct($message ?: json_last_error()); + + $this->value = $value; + } +} diff --git a/src/Exceptions/ManuallyFailedException.php b/src/Exceptions/ManuallyFailedException.php new file mode 100644 index 0000000..aa66cd3 --- /dev/null +++ b/src/Exceptions/ManuallyFailedException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Exceptions; + +use RuntimeException; + +/** + * Exception levée lorsqu'un job est marqué en échec manuellement (`fail()`). + */ +class ManuallyFailedException extends RuntimeException +{ +} diff --git a/src/Exceptions/MaxAttemptsExceededException.php b/src/Exceptions/MaxAttemptsExceededException.php new file mode 100644 index 0000000..6dcfc95 --- /dev/null +++ b/src/Exceptions/MaxAttemptsExceededException.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Exceptions; + +use BlitzPHP\Contracts\Queue\Job; +use RuntimeException; + +/** + * Exception levée lorsqu'un job a épuisé son nombre maximal de tentatives. + */ +class MaxAttemptsExceededException extends RuntimeException +{ + /** + * Instance du job concerné. + */ + public ?Job $job = null; + + /** + * Crée une instance d'exception liée au job. + */ + public static function forJob(Job $job): static + { + return tap(new static($job->resolveName() . ' has been attempted too many times.'), function ($e) use ($job) { + $e->job = $job; + }); + } +} diff --git a/src/Exceptions/TimeoutExceededException.php b/src/Exceptions/TimeoutExceededException.php new file mode 100644 index 0000000..c6b886e --- /dev/null +++ b/src/Exceptions/TimeoutExceededException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Exceptions; + +use BlitzPHP\Contracts\Queue\Job; + +/** + * Exception levée lorsqu'un job dépasse son délai d'exécution (timeout). + */ +class TimeoutExceededException extends MaxAttemptsExceededException +{ + /** + * Crée une instance d'exception liée au job. + */ + public static function forJob(Job $job): static + { + return tap(new static($job->resolveName() . ' has timed out.'), function ($e) use ($job) { + $e->job = $job; + }); + } +} diff --git a/src/Failed/CountableFailedJobProvider.php b/src/Failed/CountableFailedJobProvider.php new file mode 100644 index 0000000..80f3200 --- /dev/null +++ b/src/Failed/CountableFailedJobProvider.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +/** + * Contrat permettant de compter les jobs échoués, éventuellement par connexion et file. + */ +interface CountableFailedJobProvider +{ + /** + * Compte les jobs échoués. + */ + public function count(?string $connection = null, ?string $queue = null): int; +} diff --git a/src/Failed/DatabaseFailedJobProvider.php b/src/Failed/DatabaseFailedJobProvider.php new file mode 100644 index 0000000..7ca2032 --- /dev/null +++ b/src/Failed/DatabaseFailedJobProvider.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Database\Builder\BaseBuilder; +use BlitzPHP\Utilities\Date; +use DateTimeInterface; +use Throwable; + +/** + * Stocke les jobs échoués en base, identifiés par une clé auto-incrémentée. + */ +class DatabaseFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface, PrunableFailedJobProvider +{ + /** + * Crée un fournisseur de jobs échoués en base de données. + * + * @param ConnectionResolverInterface $resolver Résolveur de connexions base de données. + * @param string $database Nom de la connexion base de données. + * @param string $table Nom de la table. + */ + public function __construct(protected ConnectionResolverInterface $resolver, protected string $database, protected string $table) + { + } + + /** + * Enregistre un job échoué dans le stockage. + */ + public function log(string $connection, string $queue, string $payload, Throwable $exception): ?int + { + $failed_at = Date::now(); + + $exception = (string) mb_convert_encoding($exception, 'UTF-8'); + + return $this->insertGetId(compact( + 'connection', + 'queue', + 'payload', + 'exception', + 'failed_at', + )); + } + + /** + * Retourne les identifiants de tous les jobs échoués. + */ + public function ids(?string $queue = null): array + { + return $this->getTable() + ->when(null !== $queue, fn ($query) => $query->where('queue', $queue)) + ->orderBy('id', 'desc') + ->values('id'); + } + + /** + * Retourne la liste de tous les jobs échoués. + */ + public function all(): array + { + return $this->getTable()->orderBy('id', 'desc')->all(); + } + + /** + * Retourne un job échoué. + */ + public function find(int|string $id): ?object + { + return $this->getTable()->where($this->whereId($id))->first(); + } + + /** + * Supprime un job échoué du stockage. + */ + public function forget(int|string $id): bool + { + return $this->getTable()->where($this->whereId($id))->delete() > 0; + } + + /** + * Vide le stockage des jobs échoués. + */ + public function flush(?int $hours = null): void + { + $this->getTable()->when($hours, function ($query, $hours) { + $query->where('failed_at <=', Date::now()->subHours($hours)->format('Y-m-d H:i:s')); + })->delete(); + } + + /** + * Purge les entrées antérieures à la date donnée. + */ + public function prune(DateTimeInterface $before): int + { + $query = $this->getTable()->where('failed_at <', $before->format('Y-m-d H:i:s')); + + $totalDeleted = 0; + + do { + $deleted = $query->limit(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + + /** + * Compte les jobs échoués. + */ + public function count(?string $connection = null, ?string $queue = null): int + { + return $this->getTable() + ->when($connection, fn ($builder) => $builder->where('connection', $connection)) + ->when($queue, fn ($builder) => $builder->where('queue', $queue)) + ->count(); + } + + /** + * Retourne un constructeur de requêtes pour la table. + * + * @return BaseBuilder + */ + public function getTable() + { + return $this->resolver->connection($this->database)->table($this->table); + } + + /** + * Clause WHERE selon que l'identifiant est un UUID (32 caractères) ou un entier. + * + * @return array + */ + private function whereId(int|string $id): array + { + return [is_string($id) && strlen($id) === 32 ? 'uuid' : 'id' => $id]; + } + + /** + * Insère une ligne et retourne l'identifiant généré. + */ + private function insertGetId(array $data): ?int + { + ($builder = $this->getTable())->insert($data); + + return $builder->db()->lastId($this->table); + } +} diff --git a/src/Failed/DatabaseUuidFailedJobProvider.php b/src/Failed/DatabaseUuidFailedJobProvider.php new file mode 100644 index 0000000..15b8427 --- /dev/null +++ b/src/Failed/DatabaseUuidFailedJobProvider.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Database\Builder\BaseBuilder; +use BlitzPHP\Utilities\Date; +use DateTimeInterface; +use Throwable; + +/** + * Stocke les jobs échoués en base, identifiés par l'UUID du payload. + */ +class DatabaseUuidFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface, PrunableFailedJobProvider +{ + /** + * Crée un fournisseur de jobs échoués en base de données. + * + * @param ConnectionResolverInterface $resolver Résolveur de connexions base de données. + * @param string $database Nom de la connexion base de données. + * @param string $table Nom de la table. + */ + public function __construct(protected ConnectionResolverInterface $resolver, protected string $database, protected string $table) + { + } + + /** + * Enregistre un job échoué dans le stockage. + */ + public function log(string $connection, string $queue, string $payload, Throwable $exception): ?string + { + $this->getTable()->insert([ + 'uuid' => $uuid = json_decode($payload, true)['uuid'], + 'connection' => $connection, + 'queue' => $queue, + 'payload' => $payload, + 'exception' => (string) mb_convert_encoding($exception, 'UTF-8'), + 'failed_at' => Date::now()->format('Y-m-d H:i:s'), + ]); + + return $uuid; + } + + /** + * Retourne les identifiants de tous les jobs échoués. + */ + public function ids(?string $queue = null): array + { + return $this->getTable() + ->when(null !== $queue, fn ($query) => $query->where('queue', $queue)) + ->orderBy('id', 'desc') + ->values('uuid'); + } + + /** + * Retourne la liste de tous les jobs échoués. + */ + public function all(): array + { + $records = $this->getTable()->orderBy('id', 'desc')->all(); + + return collect($records)->map(function ($record) { + $record->id = $record->uuid; + unset($record->uuid); + + return $record; + })->all(); + } + + /** + * Retourne un job échoué. + */ + public function find(int|string $id): ?object + { + if ($record = $this->getTable()->where('uuid', $id)->first()) { + $record->id = $record->uuid; + unset($record->uuid); + } + + return $record; + } + + /** + * Supprime un job échoué du stockage. + */ + public function forget(int|string $id): bool + { + return $this->getTable()->where('uuid', $id)->delete() > 0; + } + + /** + * Vide le stockage des jobs échoués. + */ + public function flush(?int $hours = null): void + { + $this->getTable()->when($hours, function ($query, $hours) { + $query->where('failed_at <=', Date::now()->subHours($hours)->format('Y-m-d H:i:s')); + })->delete(); + } + + /** + * Purge les entrées antérieures à la date donnée. + */ + public function prune(DateTimeInterface $before): int + { + $query = $this->getTable()->where('failed_at <', $before->format('Y-m-d H:i:s')); + + $totalDeleted = 0; + + do { + $deleted = $query->limit(1000)->delete(); + + $totalDeleted += $deleted; + } while ($deleted !== 0); + + return $totalDeleted; + } + + /** + * Compte les jobs échoués. + */ + public function count(?string $connection = null, ?string $queue = null): int + { + return $this->getTable() + ->when($connection, fn ($builder) => $builder->where('connection', $connection)) + ->when($queue, fn ($builder) => $builder->where('queue', $queue)) + ->count(); + } + + /** + * Retourne un constructeur de requêtes pour la table. + * + * @return BaseBuilder + */ + public function getTable() + { + return $this->resolver->connection($this->database)->table($this->table); + } +} diff --git a/src/Failed/FailedJobProviderInterface.php b/src/Failed/FailedJobProviderInterface.php new file mode 100644 index 0000000..8780bd4 --- /dev/null +++ b/src/Failed/FailedJobProviderInterface.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use Throwable; + +/** + * Contrat de persistance des jobs définitivement échoués. + */ +interface FailedJobProviderInterface +{ + /** + * Enregistre un job échoué dans le stockage. + */ + public function log(string $connection, string $queue, string $payload, Throwable $exception): int|string|null; + + /** + * Retourne les identifiants de tous les jobs échoués. + * + * @return array + */ + public function ids(?string $queue = null): array; + + /** + * Retourne la liste de tous les jobs échoués. + * + * @return list + */ + public function all(): array; + + /** + * Retourne un job échoué. + */ + public function find(int|string $id): ?object; + + /** + * Supprime un job échoué du stockage. + */ + public function forget(int|string $id): bool; + + /** + * Vide le stockage des jobs échoués. + */ + public function flush(?int $hours = null): void; +} diff --git a/src/Failed/FileFailedJobProvider.php b/src/Failed/FileFailedJobProvider.php new file mode 100644 index 0000000..e7b9114 --- /dev/null +++ b/src/Failed/FileFailedJobProvider.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\Iterable\Collection; +use Closure; +use DateTimeInterface; +use Throwable; + +/** + * Stocke les jobs échoués dans un fichier JSON, avec un plafond d'entrées. + */ +class FileFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface, PrunableFailedJobProvider +{ + /** + * Crée un fournisseur de jobs échoués sur fichier. + * + * @param string $path Chemin du fichier de stockage des jobs échoués. + * @param int $limit Nombre maximal de jobs échoués à conserver. + * @param Closure|null $lockProviderResolver Résolveur du fournisseur de verrous. + */ + public function __construct(protected string $path, protected int $limit = 100, protected ?Closure $lockProviderResolver = null) + { + } + + /** + * Enregistre un job échoué dans le stockage. + */ + public function log(string $connection, string $queue, string $payload, Throwable $exception): ?int + { + return $this->lock(function () use ($connection, $queue, $payload, $exception) { + $id = json_decode($payload, true)['uuid']; + + $jobs = $this->read(); + + $failedAt = Date::now(); + + array_unshift($jobs, [ + 'id' => $id, + 'connection' => $connection, + 'queue' => $queue, + 'payload' => $payload, + 'exception' => (string) mb_convert_encoding($exception, 'UTF-8'), + 'failed_at' => $failedAt->format('Y-m-d H:i:s'), + 'failed_at_timestamp' => $failedAt->getTimestamp(), + ]); + + $this->write(array_slice($jobs, 0, $this->limit)); + + return $id; + }); + } + + /** + * Retourne les identifiants de tous les jobs échoués. + */ + public function ids(?string $queue = null): array + { + return (new Collection($this->all())) + ->when(null !== $queue, fn ($collect) => $collect->where('queue', $queue)) + ->pluck('id') + ->all(); + } + + /** + * Retourne la liste de tous les jobs échoués. + */ + public function all(): array + { + return $this->read(); + } + + /** + * Retourne un job échoué. + */ + public function find(int|string $id): ?object + { + return (new Collection($this->read())) + ->first(fn ($job) => $job->id === $id); + } + + /** + * Supprime un job échoué du stockage. + */ + public function forget(int|string $id): bool + { + return $this->lock(function () use ($id) { + $this->write($pruned = (new Collection($jobs = $this->read())) + ->reject(fn ($job) => $job->id === $id) + ->values() + ->all()); + + return count($jobs) !== count($pruned); + }); + } + + /** + * Vide le stockage des jobs échoués. + */ + public function flush(?int $hours = null): void + { + $this->prune(Date::now()->subHours($hours ?: 0)); + } + + /** + * Purge les entrées antérieures à la date donnée. + */ + public function prune(DateTimeInterface $before): int + { + return $this->lock(function () use ($before) { + $jobs = $this->read(); + + $this->write( + $prunedJobs = (new Collection($jobs)) + ->reject(fn ($job) => $job->failed_at_timestamp <= $before->getTimestamp()) + ->values() + ->all(), + ); + + return count($jobs) - count($prunedJobs); + }); + } + + /** + * Exécute le callback en détenant un verrou. + */ + protected function lock(Closure $callback): mixed + { + if (! $this->lockProviderResolver) { + return $callback(); + } + + return ($this->lockProviderResolver)() + ->lock('blitzphp-failed-jobs', 5) + ->block(10, fn () => $callback()); + } + + /** + * Lit le fichier des jobs échoués. + */ + protected function read(): array + { + if (! file_exists($this->path)) { + return []; + } + + $content = file_get_contents($this->path); + + if (empty(trim($content))) { + return []; + } + + $content = json_decode($content); + + return is_array($content) ? $content : []; + } + + /** + * Écrit le tableau de jobs dans le fichier des échecs. + */ + protected function write(array $jobs): void + { + file_put_contents( + $this->path, + json_encode($jobs, JSON_PRETTY_PRINT), + ); + } + + /** + * Compte les jobs échoués. + */ + public function count(?string $connection = null, ?string $queue = null): int + { + if (($connection ?? $queue) === null) { + return count($this->read()); + } + + return (new Collection($this->read())) + ->filter(fn ($job) => $job->connection === ($connection ?? $job->connection) && $job->queue === ($queue ?? $job->queue)) + ->count(); + } +} diff --git a/src/Failed/NullFailedJobProvider.php b/src/Failed/NullFailedJobProvider.php new file mode 100644 index 0000000..4b19408 --- /dev/null +++ b/src/Failed/NullFailedJobProvider.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use Throwable; + +/** + * Fournisseur vide : n'enregistre aucun job échoué. + */ +class NullFailedJobProvider implements CountableFailedJobProvider, FailedJobProviderInterface +{ + /** + * {@inheritDoc} + */ + public function log(string $connection, string $queue, string $payload, Throwable $exception): int|string|null + { + return null; + } + + /** + * {@inheritDoc} + */ + public function ids(?string $queue = null): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function all(): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function find(int|string $id): ?object + { + return null; + } + + /** + * {@inheritDoc} + */ + public function forget(int|string $id): bool + { + return true; + } + + /** + * {@inheritDoc} + */ + public function flush(?int $hours = null): void + { + // Ne rien faire + } + + /** + * {@inheritDoc} + */ + public function count(?string $connection = null, ?string $queue = null): int + { + return 0; + } +} diff --git a/src/Failed/PrunableFailedJobProvider.php b/src/Failed/PrunableFailedJobProvider.php new file mode 100644 index 0000000..13e0159 --- /dev/null +++ b/src/Failed/PrunableFailedJobProvider.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Failed; + +use DateTimeInterface; + +/** + * Contrat de purge des jobs échoués antérieurs à une date donnée. + */ +interface PrunableFailedJobProvider +{ + /** + * Purge les entrées antérieures à la date donnée. + */ + public function prune(DateTimeInterface $before): int; +} diff --git a/src/Job.php b/src/Job.php new file mode 100644 index 0000000..8a69717 --- /dev/null +++ b/src/Job.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use BlitzPHP\Queue\Traits\Dispatchable; +use BlitzPHP\Queue\Traits\InteractsWithQueue; +use BlitzPHP\Queue\Traits\SerializesModels; + +/** + * Classe de base des jobs métier destinés à la file d'attente. + * + * Étendez cette classe et implémentez `handle()` pour définir le travail + * à exécuter. Les traits associés permettent le dispatch, l'interaction + * avec le worker et la sérialisation des modèles. + */ +abstract class Job +{ + use Dispatchable; + use InteractsWithQueue; + use SerializesModels; + + /** + * Nombre maximal de tentatives avant échec définitif. + */ + protected int $maxTries = 3; + + /** + * Délai en secondes avant une nouvelle tentative après une exception. + */ + protected int $backoff = 60; + + /** + * Nom de la file logique sur laquelle dispatcher le job (vide = file par défaut). + */ + protected string $queue = ''; + + /** + * Retourne le nombre maximal de tentatives autorisées. + */ + public function maxTries(): int + { + return $this->maxTries; + } + + /** + * Retourne le délai d'attente (en secondes) avant retry. + */ + public function backoff(): int + { + return $this->backoff; + } + + /** + * Retourne le nom de la file cible du job. + */ + public function queue(): string + { + return $this->queue; + } +} diff --git a/src/Jobs/DatabaseJob.php b/src/Jobs/DatabaseJob.php new file mode 100644 index 0000000..86eca4f --- /dev/null +++ b/src/Jobs/DatabaseJob.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Job as JobContract; +use BlitzPHP\Queue\Drivers\DatabaseDriver; + +/** + * Job persisté et prélevé via le pilote base de données. + */ +class DatabaseJob extends Job implements JobContract +{ + /** + * Crée une nouvelle instance de job. + * + * @param DatabaseDriver $database Instance du pilote base de données. + * @param DatabaseJobRecord $job Enregistrement / payload du job en base. + */ + public function __construct(ContainerInterface $container, protected DatabaseDriver $database, protected DatabaseJobRecord $job, string $connectionName, string $queue) + { + $this->queue = $queue; + $this->container = $container; + $this->connectionName = $connectionName; + } + + /** + * Relâche le job dans la file après n secondes. + */ + public function release(int $delay = 0): void + { + parent::release($delay); + + $this->database->deleteAndRelease($this->queue, $this, $delay); + } + + /** + * Supprime le job de la file. + */ + public function delete(): void + { + parent::delete(); + + $this->database->deleteReserved($this->queue, $this->job->id); + } + + /** + * Retourne le nombre de tentatives déjà effectuées. + */ + public function attempts(): int + { + return (int) $this->job->attempts; + } + + /** + * Retourne l'identifiant du job. + */ + public function getJobId(): string + { + return (string) $this->job->id; + } + + /** + * Retourne le corps brut du job sous forme de chaîne. + */ + public function getRawBody(): string + { + return $this->job->payload; + } + + /** + * Retourne l'enregistrement SQL du job. + */ + public function getJobRecord(): DatabaseJobRecord + { + return $this->job; + } +} diff --git a/src/Jobs/DatabaseJobRecord.php b/src/Jobs/DatabaseJobRecord.php new file mode 100644 index 0000000..5b80cb6 --- /dev/null +++ b/src/Jobs/DatabaseJobRecord.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Traits\Support\InteractsWithTime; +use stdClass; + +/** + * Enveloppe d'une ligne SQL représentant un job en file d'attente. + */ +class DatabaseJobRecord +{ + use InteractsWithTime; + + /** + * Crée une instance d'enregistrement de job. + * + * @param stdClass $record Enregistrement sous-jacent du job. + */ + public function __construct(protected stdClass $record) + { + } + + /** + * Incrémente le nombre de tentatives du job. + */ + public function increment(): int + { + $this->record->attempts++; + + return $this->record->attempts; + } + + /** + * Met à jour l'horodatage de réservation du job. + */ + public function touch(): int + { + $this->record->reserved_at = $this->currentTime(); + + return $this->record->reserved_at; + } + + /** + * Accède dynamiquement aux champs de l'enregistrement. + */ + public function __get(string $key): mixed + { + return $this->record->{$key}; + } +} diff --git a/src/Jobs/FakeJob.php b/src/Jobs/FakeJob.php new file mode 100644 index 0000000..8a59d37 --- /dev/null +++ b/src/Jobs/FakeJob.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Contracts\Queue\Job as JobContract; +use BlitzPHP\Utilities\String\Text; +use DateInterval; +use DateTimeInterface; +use Throwable; + +/** + * Job factice utilisé pour tester les interactions avec la file (delete, fail, release). + */ +class FakeJob extends Job implements JobContract +{ + /** + * Délai (secondes) avec lequel le job a été relâché. + * + * @var int + */ + public $releaseDelay; + + /** + * Nombre de tentatives de traitement du job. + */ + public int $attempts = 1; + + /** + * Exception ayant provoqué l'échec du job. + * + * @var Throwable + */ + public $failedWith; + + /** + * Retourne l'identifiant du job. + */ + public function getJobId(): string + { + return (string) Text::uuid(); + } + + /** + * Retourne le corps brut (JSON) du job. + */ + public function getRawBody(): string + { + return ''; + } + + /** + * Relâche le job dans la file après n secondes. + */ + public function release(DateInterval|DateTimeInterface|int $delay = 0): void + { + $this->released = true; + $this->releaseDelay = $delay; + } + + /** + * Retourne le nombre de tentatives déjà effectuées. + */ + public function attempts(): int + { + return $this->attempts; + } + + /** + * Supprime le job de la file. + */ + public function delete(): void + { + $this->deleted = true; + } + + /** + * Supprime le job, appelle `failed()` et émet l'événement d'échec. + */ + public function fail(?Throwable $e = null): void + { + $this->failed = true; + $this->failedWith = $e; + } +} diff --git a/src/Jobs/InspectedJob.php b/src/Jobs/InspectedJob.php new file mode 100644 index 0000000..df9bbc3 --- /dev/null +++ b/src/Jobs/InspectedJob.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Utilities\Date; + +/** + * Vue en lecture seule d'un job (inspection des files en attente, retardées ou réservées). + */ +class InspectedJob +{ + /** + * Crée une instance de job inspecté. + * + * @param string|null $uuid Identifiant unique du job. + * @param string|null $name Nom d'affichage du job. + * @param int $attempts Nombre de tentatives déjà effectuées. + * @param Date|null $createdAt Date et heure de création du job. + */ + public function __construct( + public readonly ?string $uuid, + public readonly ?string $name, + public readonly int $attempts, + public readonly ?Date $createdAt, + ) { + } + + /** + * Crée une instance à partir d'un payload JSON brut. + * + * @param string $payload Payload JSON brut du job. + * @param int|null $attempts Nombre de tentatives déjà effectuées. + */ + public static function fromPayload(string $payload, ?int $attempts = null): static + { + $decoded = json_decode($payload, true); + + return new static( + uuid: $decoded['uuid'] ?? null, + name: $decoded['displayName'] ?? null, + attempts: $attempts ?? $decoded['attempts'] ?? 0, + createdAt: isset($decoded['createdAt']) ? Date::createFromTimestamp($decoded['createdAt']) : null, + ); + } +} diff --git a/src/Jobs/Job.php b/src/Jobs/Job.php new file mode 100644 index 0000000..fc01107 --- /dev/null +++ b/src/Jobs/Job.php @@ -0,0 +1,335 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Exceptions\ManuallyFailedException; +use BlitzPHP\Queue\Exceptions\TimeoutExceededException; +use BlitzPHP\Traits\Support\InteractsWithTime; +use Throwable; + +/** + * Représentation d'un job prélevé d'une file d'attente. + * + * Encapsule le payload, le cycle de vie (exécution, suppression, relâchement, + * échec) et les métadonnées (tentatives, timeout, backoff). + */ +abstract class Job +{ + use InteractsWithTime; + + /** + * Instance du handler de job résolu. + * + * @var mixed + */ + protected $instance; + + /** + * Conteneur d'injection de dépendances. + */ + protected ContainerInterface $container; + + /** + * Indique si le job a été supprimé de la file. + */ + protected bool $deleted = false; + + /** + * Indique si le job a été relâché dans la file. + */ + protected bool $released = false; + + /** + * Indique si le job a été marqué en échec. + */ + protected bool $failed = false; + + /** + * Nom de la connexion à laquelle appartient le job. + */ + protected string $connectionName; + + /** + * Nom de la file à laquelle appartient le job. + */ + protected string $queue; + + /** + * Retourne l'identifiant du job. + */ + abstract public function getJobId(): int|string|null; + + /** + * Retourne le corps brut (JSON) du job. + */ + abstract public function getRawBody(): string; + + /** + * Retourne l'UUID du job. + */ + public function uuid(): ?string + { + return $this->payload()['uuid'] ?? null; + } + + /** + * Déclenche l'exécution du job. + */ + public function fire(): void + { + $payload = $this->payload(); + + [$class, $method] = JobName::parse($payload['job']); + + ($this->instance = $this->resolve($class))->{$method}($this, $payload['data']); + } + + /** + * Supprime le job de la file. + */ + public function delete(): void + { + $this->deleted = true; + } + + /** + * Indique si le job a été supprimé. + */ + public function isDeleted(): bool + { + return $this->deleted; + } + + /** + * Relâche le job dans la file après n secondes. + */ + public function release(int $delay = 0): void + { + $this->released = true; + } + + /** + * Indique si le job a été relâché dans la file. + */ + public function isReleased(): bool + { + return $this->released; + } + + /** + * Indique si le job a été supprimé ou relâché. + */ + public function isDeletedOrReleased(): bool + { + return $this->isDeleted() || $this->isReleased(); + } + + /** + * Indique si le job a été marqué en échec. + */ + public function hasFailed(): bool + { + return $this->failed; + } + + /** + * Marque le job comme échoué. + */ + public function markAsFailed(): void + { + $this->failed = true; + } + + /** + * Supprime le job, appelle `failed()` et émet l'événement d'échec. + */ + public function fail(?Throwable $e = null): void + { + $this->markAsFailed(); + + if ($this->isDeleted()) { + return; + } + + if ($this->shouldRollBackDatabaseTransaction($e)) { + $this->container->get(ConnectionResolverInterface::class) + ->connection(config('queue.failed.database')) + ->rollBack(); + } + + try { + // En cas d'échec : suppression, appel de failed(), puis événement + // pour permettre le suivi et la journalisation des jobs échoués. + $this->delete(); + + $this->failed($e); + } finally { + $this->resolve(QueueEventManager::class)->jobFailed($this->connectionName, $this, $e ?: new ManuallyFailedException()); + } + } + + /** + * Indique si la transaction SQL courante doit être annulée jusqu'au niveau zéro. + */ + protected function shouldRollBackDatabaseTransaction(Throwable $e): bool + { + $config = config('queue.failed'); + + return $e instanceof TimeoutExceededException + && $config['database'] + && in_array($config['driver'], ['database', 'database-uuids'], true) + && $this->container->bound(ConnectionResolverInterface::class); + } + + /** + * Traite l'exception à l'origine de l'échec du job. + */ + protected function failed(?Throwable $e): void + { + $payload = $this->payload(); + + [$class] = JobName::parse($payload['job']); + + if (method_exists($this->instance = $this->resolve($class), 'failed')) { + $this->instance->failed($payload['data'], $e, $payload['uuid'] ?? '', $this); + } + } + + /** + * Résout la classe donnée via le conteneur. + */ + protected function resolve(string $class): mixed + { + return $this->container->make($class); + } + + /** + * Retourne l'instance du handler déjà résolue. + */ + public function getResolvedJob(): mixed + { + return $this->instance; + } + + /** + * Retourne le corps du job décodé (tableau). + */ + public function payload(): array + { + return json_decode($this->getRawBody(), true); + } + + /** + * Retourne le nombre maximal de tentatives du job. + */ + public function maxTries(): ?int + { + return $this->payload()['maxTries'] ?? null; + } + + /** + * Retourne le nombre maximal d'exceptions avant échec définitif. + */ + public function maxExceptions(): ?int + { + return $this->payload()['maxExceptions'] ?? null; + } + + /** + * Indique si le job doit échouer en cas de dépassement de délai. + */ + public function shouldFailOnTimeout(): bool + { + return $this->payload()['failOnTimeout'] ?? false; + } + + /** + * Secondes d'attente avant de relancer un job ayant levé une exception non gérée. + * + * @return int|list|null + */ + public function backoff() + { + return $this->payload()['backoff'] ?? $this->payload()['delay'] ?? null; + } + + /** + * Retourne la durée maximale d'exécution du job (secondes). + */ + public function timeout(): ?int + { + return $this->payload()['timeout'] ?? null; + } + + /** + * Retourne l'horodatage limite au-delà duquel le job ne doit plus être retenté. + */ + public function retryUntil(): ?int + { + return $this->payload()['retryUntil'] ?? null; + } + + /** + * Retourne le nom du handler de job enfilé. + */ + public function getName(): string + { + return $this->payload()['job']; + } + + /** + * Retourne le nom d'affichage résolu du job. + * + * Résout le nom des jobs « enveloppés » (handlers de classe). + */ + public function resolveName(): string + { + return JobName::resolve($this->getName(), $this->payload()); + } + + /** + * Retourne la classe du job enfilé. + * + * Résout la classe des jobs « enveloppés » (handlers de classe). + */ + public function resolveQueuedJobClass(): string + { + return JobName::resolveClassName($this->getName(), $this->payload()); + } + + /** + * Retourne le nom de la connexion du job. + */ + public function getConnectionName(): string + { + return $this->connectionName; + } + + /** + * Retourne le nom de la file du job. + */ + public function getQueue(): string + { + return $this->queue; + } + + /** + * Retourne le conteneur de services. + */ + public function getContainer(): ContainerInterface + { + return $this->container; + } +} diff --git a/src/Jobs/JobName.php b/src/Jobs/JobName.php new file mode 100644 index 0000000..66381db --- /dev/null +++ b/src/Jobs/JobName.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Utilities\String\Text; + +/** + * Utilitaires de résolution du nom et de la classe d'un job enfilé. + */ +class JobName +{ + /** + * Découpe le nom du job en tableau [classe, méthode]. + */ + public static function parse(string $job): array + { + return Text::parseCallback($job, 'fire'); + } + + /** + * Retourne le nom résolu de la classe de job. + */ + public static function resolve(string $name, array $payload): string + { + if (! empty($payload['displayName'])) { + return $payload['displayName']; + } + + return $name; + } + + /** + * Retourne le nom de classe du job enfilé. + * + * @param array $payload + */ + public static function resolveClassName(string $name, array $payload): string + { + if (is_string($payload['data']['commandName'] ?? null)) { + return $payload['data']['commandName']; + } + + return $name; + } +} diff --git a/src/Jobs/SyncJob.php b/src/Jobs/SyncJob.php new file mode 100644 index 0000000..be80060 --- /dev/null +++ b/src/Jobs/SyncJob.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Jobs; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Queue\Job as JobContract; + +/** + * Job exécuté immédiatement par le pilote synchrone (sans persistance). + */ +class SyncJob extends Job implements JobContract +{ + /** + * Nom de classe du job. + * + * @var string + */ + protected $job; + + /** + * Crée une nouvelle instance de job. + * + * @param string $payload Données du message de file. + */ + public function __construct(ContainerInterface $container, protected string $payload, string $connectionName, string $queue) + { + $this->queue = $queue; + $this->container = $container; + $this->connectionName = $connectionName; + } + + /** + * Relâche le job dans la file après n secondes. + */ + public function release(int $delay = 0): void + { + parent::release($delay); + } + + /** + * Retourne le nombre de tentatives déjà effectuées. + */ + public function attempts(): int + { + return 1; + } + + /** + * Retourne l'identifiant du job. + */ + public function getJobId(): string + { + return ''; + } + + /** + * Retourne le corps brut du job sous forme de chaîne. + */ + public function getRawBody(): string + { + return $this->payload; + } + + /** + * Retourne le nom de la file du job. + */ + public function getQueue(): string + { + return 'sync'; + } +} diff --git a/src/Manager.php b/src/Manager.php new file mode 100644 index 0000000..c132736 --- /dev/null +++ b/src/Manager.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use BlitzPHP\Cache\Cache; +use BlitzPHP\Contracts\Cache\CacheInterface; +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Event\EventManagerInterface; +use BlitzPHP\Contracts\Queue\Factory; +use BlitzPHP\Contracts\Queue\Monitor; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Queue\DTO\Config; +use BlitzPHP\Queue\Events\QueueEventManager; +use DateInterval; +use DateTimeInterface; +use InvalidArgumentException; +use UnitEnum; + +/** + * Gestionnaire des files d'attente. + * + * Résout les pilotes, expose les connexions et permet d'écouter le cycle de vie + * des jobs et des workers. Les appels magiques sont délégués à la connexion par défaut. + * + * @mixin QueueContract + */ +class Manager implements Factory, Monitor +{ + /** + * Instances de pilotes déjà résolues, indexées par nom de connexion. + * + * @var array + */ + protected array $drivers = []; + + /** + * Gestionnaire d'événements de la file. + */ + protected QueueEventManager $queueEventManager; + + /** + * Cache applicatif (pause / redémarrage des workers). + */ + protected Cache $cache; + + /** + * Gestionnaire d'événements de l'application. + */ + protected EventManagerInterface $events; + + /** + * Crée une instance du gestionnaire de files. + */ + public function __construct(protected ContainerInterface $container, protected Config $config) + { + $this->cache = $container->get(CacheInterface::class); + $this->events = $container->get(EventManagerInterface::class); + } + + /** + * Enregistre un écouteur exécuté avant le traitement d'un job. + */ + public function before(callable $callback): void + { + $this->events->on(QueueEventManager::JOB_PROCESSING, $callback); + } + + /** + * Enregistre un écouteur exécuté après le traitement réussi d'un job. + */ + public function after(callable $callback): void + { + $this->events->on(QueueEventManager::JOB_PROCESSED, $callback); + } + + /** + * Enregistre un écouteur lorsqu'une exception survient pendant un job. + */ + public function exceptionOccurred(callable $callback): void + { + $this->events->on(QueueEventManager::JOB_EXCEPTION_OCCURED, $callback); + } + + /** + * Enregistre un écouteur à chaque itération de la boucle du daemon. + */ + public function looping(callable $callback): void + { + $this->events->on(QueueEventManager::JOB_LOOPING, $callback); + } + + /** + * Enregistre un écouteur lorsqu'un job échoue définitivement. + */ + public function failing(callable $callback): void + { + $this->events->on(QueueEventManager::JOB_FAILED, $callback); + } + + /** + * Enregistre un écouteur au démarrage du worker daemon. + */ + public function starting(callable $callback): void + { + $this->events->on(QueueEventManager::WORKER_STARTING, $callback); + } + + /** + * Enregistre un écouteur à l'arrêt du worker daemon. + */ + public function stopping(callable $callback): void + { + $this->events->on(QueueEventManager::WORKER_STOPPING, $callback); + } + + /** + * Retourne (et instancie si besoin) le gestionnaire d'événements de file. + */ + protected function queueEventManager(): QueueEventManager + { + if (! $this->queueEventManager) { + $this->queueEventManager = $this->container->get(QueueEventManager::class); + } + + return $this->queueEventManager; + } + + /** + * Indique si le pilote (connexion) donné est déjà résolu. + */ + public function connected(string|UnitEnum|null $name = null): bool + { + $name = $name instanceof UnitEnum ? $name->name : ($name ?: $this->getDefaultDriver()); + + return isset($this->drivers[$name]); + } + + /** + * Résout une instance de connexion de file d'attente. + * + * Les pilotes sont instanciés à la demande pour éviter les connexions inutiles. + */ + public function driver(string|UnitEnum|null $name = null): QueueContract + { + $name = $name instanceof UnitEnum ? $name->name : ($name ?: $this->getDefaultDriver()); + + // Si le pilote n'a pas encore été résolu, on l'instancie maintenant : + // les connexions ne sont ouvertes que lorsqu'elles sont réellement utilisées. + if (! isset($this->drivers[$name])) { + $this->drivers[$name] = $this->resolve($name); + + $this->drivers[$name]->setContainer($this->container); + } + + return $this->drivers[$name]; + } + + /** + * Instancie une connexion à partir de sa configuration. + * + * @throws InvalidArgumentException + */ + protected function resolve(string $name): Queue + { + $config = $this->config->connection($name); + $driver = $this->config->driver($config['driver']); + + $queue = $driver::connect($this->container, $config)->setConnectionName($name); + + if (method_exists($queue, 'setConfig')) { + $queue->setConfig($config); + } + + return $queue; + } + + /** + * Met une file en pause (les workers cessent d'y prélever des jobs). + */ + public function pause(string $connection, string $queue): void + { + $this->cache->forever("blitzphp-queue-paused-{$connection}-{$queue}", true); + + $this->queueEventManager()->queuePaused($connection, $queue); + } + + /** + * Met une file en pause pendant une durée donnée. + */ + public function pauseFor(string $connection, string $queue, DateInterval|DateTimeInterface|int $ttl): void + { + $convertedTtl = $ttl instanceof DateTimeInterface ? $ttl->getTimestamp() : $ttl; + + $this->cache->set("blitzphp-queue-paused-{$connection}-{$queue}", true, $convertedTtl); + + $this->queueEventManager()->queuePaused($connection, $queue, $ttl); + } + + /** + * Reprend une file précédemment mise en pause. + */ + public function resume(string $connection, string $queue): void + { + $this->cache->delete("blitzphp-queue-paused-{$connection}-{$queue}"); + + $this->queueEventManager()->queueResumed($connection, $queue); + } + + /** + * Indique si une file est actuellement en pause. + */ + public function isPaused(string $connection, string $queue): bool + { + return (bool) $this->cache->get("blitzphp-queue-paused-{$connection}-{$queue}", false); + } + + /** + * Désactive le sondage cache des signaux de pause et de redémarrage. + * + * Évite que les workers interrogent le cache applicatif pour savoir s'ils + * doivent se mettre en pause ou redémarrer. + */ + public function withoutInterruptionPolling(): void + { + Worker::$restartable = false; + Worker::$pausable = false; + } + + /** + * Retourne le nom de la connexion par défaut. + */ + public function getDefaultDriver(): string + { + return $this->config->default; + } + + /** + * Définit le nom de la connexion par défaut. + */ + public function setDefaultDriver(string $name): void + { + $this->config->setDefaultDriver($name); + } + + /** + * Retourne le nom effectif d'une connexion (ou la connexion par défaut). + */ + public function getName(?string $connection = null): string + { + return $connection ?: $this->getDefaultDriver(); + } + + /** + * Retourne le conteneur utilisé par le gestionnaire. + */ + public function getContainer(): ContainerInterface + { + return $this->container; + } + + /** + * Définit le conteneur et le propage aux pilotes déjà résolus. + */ + public function setContainer(ContainerInterface $container): self + { + $this->container = $container; + + foreach ($this->drivers as $driver) { + $driver->setContainer($container); + } + + return $this; + } + + /** + * Délègue dynamiquement les appels à la connexion par défaut. + */ + public function __call(string $method, array $parameters = []): mixed + { + return $this->driver()->{$method}(...$parameters); + } +} diff --git a/src/Models/JobModel.php b/src/Models/JobModel.php new file mode 100644 index 0000000..15c7db3 --- /dev/null +++ b/src/Models/JobModel.php @@ -0,0 +1,228 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Models; + +use BlitzPHP\Contracts\Database\ConnectionInterface; +use BlitzPHP\Contracts\Database\ConnectionResolverInterface; +use BlitzPHP\Database\Builder\BaseBuilder; +use BlitzPHP\Database\Connection\BaseConnection; +use BlitzPHP\Database\Model; +use BlitzPHP\Traits\Support\InteractsWithTime; +use BlitzPHP\Utilities\Date; +use Throwable; + +/** + * Modèle des lignes de la table des jobs en file d'attente. + */ +class JobModel extends Model +{ + use InteractsWithTime; + + /** + * Format de stockage des dates (horodatage Unix). + */ + protected string $dateFormat = 'int'; + + /** + * Désactive les callbacks du modèle pendant les opérations de file. + */ + protected bool $allowCallbacks = false; + + /** + * Délai d'expiration d'un job réservé (secondes). + */ + protected ?int $retryAfter = 60; + + /** + * @param array $config Configuration de la connexion `database`. + * @param ConnectionResolverInterface $resolver Résolveur de connexions. + * @param ConnectionInterface $db Connexion SQL utilisée. + */ + public function __construct(array $config, protected ConnectionResolverInterface $resolver, ConnectionInterface $db) + { + assert($db instanceof BaseConnection); + + $this->table = $config['table']; + $this->retryAfter = $config['retry_after'] ?? 60; + + // Désactive le mode transaction strict + $db->transStrict(false); + + parent::__construct($resolver, $db); + } + + /** + * Retourne le nombre total de jobs dans la file. + */ + public function size(string $queue): int + { + return $this->builder() + ->where('queue', $queue) + ->count(); + } + + /** + * Retourne le nombre de jobs en attente. + */ + public function pendingSize(string $queue): int + { + return $this->builder() + ->where('queue', $queue) + ->where('available_at <=', $this->currentTime()) + ->whereNull('reserved_at') + ->count(); + } + + /** + * Retourne le nombre de jobs retardés. + */ + public function delayedSize(string $queue): int + { + return $this->builder() + ->where('queue', ${$queue}) + ->where('available_at >', $this->currentTime()) + ->whereNull('reserved_at') + ->count(); + } + + /** + * Retourne le nombre de jobs réservés. + */ + public function reservedSize(string $queue): int + { + return $this->builder() + ->where('queue', ${$queue}) + ->whereNotNull('reserved_at') + ->count(); + } + + /** + * Retourne les jobs en attente de la file donnée. + */ + public function pendingJobs(string $queue): array + { + return $this->builder() + ->where('queue', $queue) + ->where('available_at <=', $this->currentTime()) + ->whereNull('reserved_at') + ->all(); + } + + /** + * Retourne les jobs retardés de la file donnée. + */ + public function delayedJobs(string $queue): array + { + return $this->builder() + ->where('queue', $queue) + ->where('available_at >', $this->currentTime()) + ->whereNull('reserved_at') + ->all(); + } + + /** + * Retourne les jobs réservés de la file donnée. + */ + public function reservedJobs(string $queue): array + { + return $this->builder() + ->where('queue', $queue) + ->whereNotNull('reserved_at') + ->all(); + } + + /** + * Retourne l'horodatage de création du plus ancien job en attente (hors retardés). + */ + public function creationTimeOfOldestPendingJob(string $queue): ?int + { + return $this->builder() + ->where('queue', $queue) + ->where('available_at <=', $this->currentTime()) + ->whereNull('reserved_at') + ->sortAsc('available_at') + ->value('available_at'); + } + + /** + * Insère un payload brut en base avec un délai de n secondes. + */ + public function pushToDatabase(array $data): mixed + { + $this->builder()->insert($data); + + return $this->db->lastId($this->table); + } + + /** + * Retourne le prochain job disponible de la file. + */ + public function getNextAvailableJob(string $queue): ?object + { + return $this->builder() + // ->lock($this->getLockForPopping()) disponible uniquement avec blitz-php/database > 1.2 + ->where('queue', $queue) + ->where(function ($query) { + $this->isAvailable($query); + $this->isReservedButExpired($query); + }) + ->orderBy('id', 'asc') + ->first(); + } + + /** + * Supprime un job réservé de la file. + * + * @throws Throwable + */ + public function deleteReserved(string $queue, string $id): void + { + $this->db->transaction(function () use ($id) { + if ($this/* ->lockForUpdate() */ ->where('id', $id)->first()) { + $this->where('id', $id)->delete(); + } + }); + } + + /** + * Supprime tous les jobs de la file. + */ + public function clear(string $queue): bool + { + $this->builder()->where('queue', $queue)->delete(); + + return true; + } + + /** + * Restreint la requête aux jobs disponibles. + */ + protected function isAvailable(BaseBuilder $query): void + { + $query->where(function ($query) { + $query->whereNull('reserved_at') + ->where('available_at <=', $this->currentTime()); + }); + } + + /** + * Inclut les jobs réservés dont le verrou a expiré. + */ + protected function isReservedButExpired(BaseBuilder $query): void + { + $expiration = Date::now()->subSeconds($this->retryAfter)->getTimestamp(); + + $query->orWhere(function ($query) use ($expiration) { + $query->where('reserved_at <=', $expiration); + }); + } +} diff --git a/src/Providers/QueueProvider.php b/src/Providers/QueueProvider.php new file mode 100644 index 0000000..4c825f3 --- /dev/null +++ b/src/Providers/QueueProvider.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Providers; + +use BlitzPHP\Container\AbstractProvider; +use BlitzPHP\Contracts\Queue\Factory; +use BlitzPHP\Contracts\Queue\Monitor; +use BlitzPHP\Queue\Manager; +use BlitzPHP\Queue\Worker; + +/** + * Fournisseur de services : lie Factory, Monitor, Manager et Worker au conteneur. + */ +class QueueProvider extends AbstractProvider +{ + /** + * {@inheritDoc} + */ + public static function definitions(): array + { + return [ + Factory::class => static fn () => service('queue'), + Monitor::class => static fn () => service('queue'), + Manager::class => static fn () => service('queue'), + Worker::class => static fn () => service('worker'), + ]; + } +} diff --git a/src/Queue.php b/src/Queue.php new file mode 100644 index 0000000..bbeca3e --- /dev/null +++ b/src/Queue.php @@ -0,0 +1,436 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Event\EventManagerInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue as QueueContract; +use BlitzPHP\Contracts\Security\EncrypterInterface; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Exceptions\InvalidPayloadException; +use BlitzPHP\Traits\Support\InteractsWithTime; +use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\Iterable\Collection; +use BlitzPHP\Utilities\String\Uuid; +use Closure; +use DateInterval; +use DateTimeInterface; +use RuntimeException; +use Throwable; + +/** + * Classe de base des connexions de file d'attente. + * + * Encapsule la création du payload JSON, les hooks de sérialisation, + * le dispatch et l'émission des événements d'enfilement. + */ +abstract class Queue implements QueueContract +{ + use InteractsWithTime; + + /** + * Conteneur d'injection de dépendances. + */ + protected ContainerInterface $container; + + /** + * Gestionnaire d'événements de la file. + */ + protected ?QueueEventManager $eventManager = null; + + /** + * Nom de la connexion (clé de `queue.connections`). + */ + protected string $connectionName = ''; + + /** + * Configuration brute de la connexion. + */ + protected array $config; + + /** + * Indique si les jobs doivent être envoyés après le commit des transactions SQL. + */ + protected bool $dispatchAfterCommit; + + /** + * Callbacks exécutés lors de la construction du payload. + * + * @var list + */ + protected static array $createPayloadCallbacks = []; + + /** + * Envoie un job sur une file nommée. + */ + public function pushOn(string $queue, object|string $job, mixed $data = ''): mixed + { + return $this->push($job, $data, $queue); + } + + /** + * Envoie un job sur une file nommée, avec un délai en secondes. + */ + public function laterOn(string $queue, DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = ''): mixed + { + return $this->later($delay, $job, $data, $queue); + } + + /** + * Envoie plusieurs jobs sur la file. + * + * @param array $jobs + * + * @return void + */ + public function bulk(array $jobs, mixed $data = '', ?string $queue = null) + { + foreach ($jobs as $job) { + $this->push($job, $data, $queue); + } + } + + /** + * {@inheritDoc} + */ + public function clear(string $queue): bool + { + return true; + } + + /** + * Construit la chaîne JSON du payload à partir du job et des données. + * + * @throws InvalidPayloadException Si l'encodage JSON échoue. + */ + protected function createPayload(object|string $job, string $queue, mixed $data = '', DateInterval|DateTimeInterface|int|null $delay = null): ?string + { + if ($job instanceof Closure) { + $job = CallQueuedClosure::create($job); + } + + $value = $this->createPayloadArray($job, $queue, $data); + + $value['delay'] = isset($delay) + ? $this->secondsUntil($delay) + : null; + + $payload = json_encode($value, \JSON_UNESCAPED_UNICODE); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidPayloadException( + 'Unable to JSON encode payload. Error (' . json_last_error() . '): ' . json_last_error_msg(), + $value, + ); + } + + return $payload; + } + + /** + * Construit le tableau de payload (objet métier ou handler sous forme de chaîne). + */ + protected function createPayloadArray(object|string $job, string $queue, mixed $data = ''): array + { + return is_object($job) + ? $this->createObjectPayload($job, $queue) + : $this->createStringPayload($job, $queue, $data); + } + + /** + * Construit le payload d'un handler objet (job sérialisé, éventuellement chiffré). + * + * @throws RuntimeException Si la sérialisation du job échoue. + */ + protected function createObjectPayload(object $job, string $queue): array + { + $payload = $this->withCreatePayloadHooks($queue, [ + 'uuid' => (string) Uuid::v4(), + 'displayName' => $this->getDisplayName($job), + 'job' => 'BlitzPHP\Queue\CallQueuedHandler@call', + 'maxTries' => $this->getJobTries($job), + 'maxExceptions' => $job->maxExceptions ?? null, + 'failOnTimeout' => $job->failOnTimeout ?? false, + 'backoff' => $this->getJobBackoff($job), + 'timeout' => $job->timeout ?? null, + 'retryUntil' => $this->getJobExpiration($job), + 'deleteWhenMissingModels' => $job->deleteWhenMissingModels ?? false, + 'data' => [ + 'commandName' => $job, + 'command' => $job, + 'batchId' => $job->batchId ?? null, + ], + 'createdAt' => Date::now()->getTimestamp(), + ]); + + try { + $command = $this->jobShouldBeEncrypted($job) && $this->container->bound(EncrypterInterface::class) + ? $this->container->get(EncrypterInterface::class)->encrypt(serialize(clone $job)) + : serialize(clone $job); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf('Failed to serialize job of type [%s]: %s', $job::class, $e->getMessage()), + 0, + $e, + ); + } + + return array_merge($payload, [ + 'data' => array_merge($payload['data'], [ + 'commandName' => $job::class, + 'command' => $command, + ]), + ]); + } + + /** + * Retourne le nom d'affichage du job (méthode `displayName()` ou FQCN). + */ + protected function getDisplayName(object $job): string + { + return method_exists($job, 'displayName') + ? $job->displayName() + : $job::class; + } + + /** + * Retourne le nombre maximal de tentatives défini sur le job objet. + */ + public function getJobTries(object $job): mixed + { + $tries = $job->tries ?? null; + + if (method_exists($job, 'tries')) { + $tries = $job->tries(); + } + + return $tries; + } + + /** + * Retourne le backoff (délai de retry) du job objet, sous forme de liste CSV. + */ + public function getJobBackoff(object $job): mixed + { + $backoff = null; + + if (method_exists($job, 'backoff')) { + $backoff = $job->backoff(); + } elseif (property_exists($job, 'backoff')) { + $backoff = $job->backoff ?? null; + } + + if (null === $backoff) { + return null; + } + + return Collection::wrap($backoff) + ->map(fn ($backoff) => $backoff instanceof DateTimeInterface ? $this->secondsUntil($backoff) : $backoff) + ->implode(','); + } + + /** + * Retourne l'horodatage d'expiration (`retryUntil`) du job objet. + */ + public function getJobExpiration(object $job): mixed + { + if (! method_exists($job, 'retryUntil') && ! isset($job->retryUntil)) { + return null; + } + + $expiration = $job->retryUntil ?? $job->retryUntil(); + + return $expiration instanceof DateTimeInterface + ? $expiration->getTimestamp() + : $expiration; + } + + /** + * Indique si le job doit être chiffré avant d'être persisté. + */ + protected function jobShouldBeEncrypted(object $job): bool + { + return isset($job->shouldBeEncrypted) && $job->shouldBeEncrypted; + } + + /** + * Construit un payload classique pour un handler identifié par une chaîne (`Classe@méthode`). + */ + protected function createStringPayload(string $job, string $queue, mixed $data): array + { + return $this->withCreatePayloadHooks($queue, [ + 'uuid' => (string) Uuid::v4(), + 'displayName' => is_string($job) ? explode('@', $job)[0] : null, + 'job' => $job, + 'maxTries' => null, + 'maxExceptions' => null, + 'failOnTimeout' => false, + 'backoff' => null, + 'timeout' => null, + 'data' => $data, + 'createdAt' => Date::now()->getTimestamp(), + ]); + } + + /** + * Enregistre un callback exécuté à la création des payloads (`null` pour tout réinitialiser). + */ + public static function createPayloadUsing(?callable $callback = null): void + { + if (null === $callback) { + static::$createPayloadCallbacks = []; + } else { + static::$createPayloadCallbacks[] = $callback; + } + } + + /** + * Applique les hooks enregistrés au tableau de payload. + */ + protected function withCreatePayloadHooks(string $queue, array $payload): array + { + if (! empty(static::$createPayloadCallbacks)) { + foreach (static::$createPayloadCallbacks as $callback) { + $payload = array_merge($payload, $callback($this->getConnectionName(), $queue, $payload)); + } + } + + return $payload; + } + + /** + * Enfile un job via le callback fourni, après avoir émis les événements d'enfilement. + */ + protected function enqueueUsing(object|string $job, string $payload, ?string $queue, DateInterval|DateTimeInterface|int|null $delay, callable $callback): mixed + { + /* + if ($this->shouldDispatchAfterCommit($job) && $this->container->bound('db.transactions')) { + if ($job->shouldBeUnique) { + $this->container->make('db.transactions')->addCallbackForRollback( + function () use ($job) { + (new UniqueLock($this->container->make(Cache::class)))->release($job); + } + ); + } + + return $this->container->make('db.transactions')->addCallback( + function () use ($queue, $job, $payload, $delay, $callback) { + $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); + + return tap($callback($payload, $queue, $delay), function ($jobId) use ($queue, $job, $payload, $delay) { + $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); + }); + } + ); + } + */ + + $this->raiseJobQueueingEvent($queue, $job, $payload, $delay); + + return tap($callback($payload, $queue, $delay), function ($jobId) use ($queue, $job, $payload, $delay) { + $this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay); + }); + } + + /** + * Indique si le job doit attendre le commit des transactions SQL avant d'être envoyé. + */ + protected function shouldDispatchAfterCommit(object|string $job): bool + { + if (! $job instanceof Closure && is_object($job) && isset($job->afterCommit)) { + return $job->afterCommit; + } + + return $this->dispatchAfterCommit ?? false; + } + + /** + * Émet l'événement « job en cours d'enfilement ». + */ + protected function raiseJobQueueingEvent(?string $queue, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay): void + { + $this->eventManager()->jobQueueing($this->connectionName, $queue, $job, $payload, $delay); + } + + /** + * Émet l'événement « job enfilé ». + */ + protected function raiseJobQueuedEvent(?string $queue, int|string|null $jobId, object|string $job, string $payload, DateInterval|DateTimeInterface|int|null $delay) + { + $this->eventManager()->jobQueued($this->connectionName, $queue, $jobId, $job, $payload, $delay); + } + + /** + * Retourne (et instancie si besoin) le gestionnaire d'événements de la file. + */ + protected function eventManager(): QueueEventManager + { + if (! $this->eventManager) { + $this->eventManager = new QueueEventManager($this->container->get(EventManagerInterface::class)); + } + + return $this->eventManager; + } + + /** + * Retourne le nom de la connexion. + */ + public function getConnectionName(): string + { + return $this->connectionName; + } + + /** + * Définit le nom de la connexion. + */ + public function setConnectionName(string $name): self + { + $this->connectionName = $name; + + return $this; + } + + /** + * Retourne le tableau de configuration de la connexion. + */ + public function getConfig(): array + { + return $this->config; + } + + /** + * Définit le tableau de configuration de la connexion. + */ + public function setConfig(array $config): self + { + $this->config = $config; + + return $this; + } + + /** + * Retourne le conteneur IoC utilisé par la connexion. + */ + public function getContainer(): ContainerInterface + { + return $this->container; + } + + /** + * Définit le conteneur IoC. + */ + public function setContainer(ContainerInterface $container): void + { + $this->container = $container; + } +} diff --git a/src/Traits/Dispatchable.php b/src/Traits/Dispatchable.php new file mode 100644 index 0000000..8e28706 --- /dev/null +++ b/src/Traits/Dispatchable.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Traits; + +use BlitzPHP\Queue\Config\Services; +use DateInterval; +use DateTimeInterface; + +/** + * Permet de dispatcher un job via des méthodes statiques (`dispatch`, `dispatchLater`, etc.). + */ +trait Dispatchable +{ + /** + * Dispatch le job sur la queue + */ + public static function dispatch(mixed ...$parameters): mixed + { + $job = new static(...$parameters); + + return Services::queue()->push($job, queue: $job->queue ?? null); + } + + /** + * Dispatch le job sur une queue spécifique + */ + public static function dispatchOn(string $queue, mixed ...$parameters): mixed + { + $job = new static(...$parameters); + + return Services::queue()->pushOn($queue, $job); + } + + /** + * Dispatch le job avec délai + */ + public static function dispatchLater(DateInterval|DateTimeInterface|int $delay, mixed ...$parameters): mixed + { + $job = new static(...$parameters); + + return Services::queue()->later($delay, $job, queue: $job->queue ?? null); + } + + /** + * Dispatch le job sur une queue spécifique avec délai + */ + public static function dispatchLaterOn(string $queue, DateInterval|DateTimeInterface|int $delay, ...$parameters): mixed + { + $job = new static(...$parameters); + + return Services::queue()->laterOn($queue, $delay, $job); + } + + /** + * Dispatch le job immédiatement (synchrone) + */ + public static function dispatchSync(mixed ...$parameters): void + { + $job = new static(...$parameters); + + $job->handle(); + } +} diff --git a/src/Traits/InteractsWithQueue.php b/src/Traits/InteractsWithQueue.php new file mode 100644 index 0000000..b95e468 --- /dev/null +++ b/src/Traits/InteractsWithQueue.php @@ -0,0 +1,266 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Traits; + +use BlitzPHP\Contracts\Queue\Job as JobContract; +use BlitzPHP\Queue\Exceptions\ManuallyFailedException; +use BlitzPHP\Queue\Jobs\FakeJob; +use BlitzPHP\Traits\Support\InteractsWithTime; +use DateInterval; +use DateTimeInterface; +use InvalidArgumentException; +use PHPUnit\Framework\Assert as PHPUnit; +use RuntimeException; +use Throwable; + +/** + * Interactions d'un job métier avec la file (delete, fail, release) et assertions de test. + */ +trait InteractsWithQueue +{ + use InteractsWithTime; + + /** + * Instance de job de file sous-jacente. + */ + public ?JobContract $job = null; + + /** + * Retourne le nombre de tentatives déjà effectuées. + */ + public function attempts(): int + { + return $this->job ? $this->job->attempts() : 1; + } + + /** + * Supprime le job de la file. + */ + public function delete(): void + { + if ($this->job) { + $this->job->delete(); + } + } + + /** + * Marque le job en échec depuis la file. + * + * @throws InvalidArgumentException + */ + public function fail(string|Throwable|null $exception = null): void + { + if (is_string($exception)) { + $exception = new ManuallyFailedException($exception); + } + + if ($exception instanceof Throwable || null === $exception) { + if ($this->job) { + $this->job->fail($exception); + } + } else { + throw new InvalidArgumentException('The fail method requires a string or an instance of Throwable.'); + } + } + + /** + * Relâche le job dans la file après n secondes. + */ + public function release(DateInterval|DateTimeInterface|int $delay = 0): void + { + $delay = $delay instanceof DateTimeInterface + ? $this->secondsUntil($delay) + : $delay; + + if ($this->job) { + $this->job->release($delay); + } + } + + /** + * Active le mode simulé pour fail, delete et release. + */ + public function withFakeQueueInteractions(): self + { + $this->job = new FakeJob(); + + return $this; + } + + /** + * Vérifie que le job a été supprimé de la file. + */ + public function assertDeleted(): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + /* PHPUnit::assertTrue( + $this->job->isDeleted(), + 'Job was expected to be deleted, but was not.' + ); */ + + return $this; + } + + /** + * Vérifie que le job n'a pas été supprimé de la file. + */ + public function assertNotDeleted(): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + /* PHPUnit::assertTrue( + ! $this->job->isDeleted(), + 'Job was unexpectedly deleted.' + ); */ + + return $this; + } + + /** + * Vérifie que le job a été marqué en échec manuellement. + */ + public function assertFailed(): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + /* PHPUnit::assertTrue( + $this->job->hasFailed(), + 'Job was expected to be manually failed, but was not.' + ); */ + + return $this; + } + + /** + * Vérifie que le job a échoué manuellement avec une exception donnée. + */ + public function assertFailedWith(string|Throwable $exception): self + { + $this->assertFailed(); + + if (is_string($exception) && class_exists($exception)) { + /* PHPUnit::assertInstanceOf( + $exception, + $this->job->failedWith, + 'Expected job to be manually failed with ['.$exception.'] but job failed with ['.get_class($this->job->failedWith).'].' + ); */ + + return $this; + } + + if (is_string($exception)) { + $exception = new ManuallyFailedException($exception); + } + + if ($exception instanceof Throwable) { + /* PHPUnit::assertInstanceOf( + get_class($exception), + $this->job->failedWith, + 'Expected job to be manually failed with ['.get_class($exception).'] but job failed with ['.get_class($this->job->failedWith).'].' + ); + + PHPUnit::assertEquals( + $exception->getCode(), + $this->job->failedWith->getCode(), + 'Expected exception code ['.$exception->getCode().'] but job failed with exception code ['.$this->job->failedWith->getCode().'].' + ); + + PHPUnit::assertEquals( + $exception->getMessage(), + $this->job->failedWith->getMessage(), + 'Expected exception message ['.$exception->getMessage().'] but job failed with exception message ['.$this->job->failedWith->getMessage().'].'); + */ + } + + return $this; + } + + /** + * Vérifie que le job n'a pas été marqué en échec manuellement. + */ + public function assertNotFailed(): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + /* PHPUnit::assertTrue( + ! $this->job->hasFailed(), + 'Job was unexpectedly failed manually.' + ); */ + + return $this; + } + + /** + * Vérifie que le job a été relâché dans la file. + */ + public function assertReleased(DateInterval|DateTimeInterface|int|null $delay = null): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + $delay = $delay instanceof DateTimeInterface + ? $this->secondsUntil($delay) + : $delay; + + /* PHPUnit::assertTrue( + $this->job->isReleased(), + 'Job was expected to be released, but was not.' + ); */ + + if (null !== $delay) { + /* PHPUnit::assertSame( + $delay, + $this->job->releaseDelay, + "Expected job to be released with delay of [{$delay}] seconds, but was released with delay of [{$this->job->releaseDelay}] seconds." + ); */ + } + + return $this; + } + + /** + * Vérifie que le job n'a pas été relâché dans la file. + */ + public function assertNotReleased(): self + { + $this->ensureQueueInteractionsHaveBeenFaked(); + + /* PHPUnit::assertTrue( + ! $this->job->isReleased(), + 'Job was unexpectedly released.' + ); */ + + return $this; + } + + /** + * S'assure que les interactions de file ont été simulées. + * + * @throws RuntimeException + */ + private function ensureQueueInteractionsHaveBeenFaked(): void + { + if (! $this->job instanceof FakeJob) { + throw new RuntimeException('Queue interactions have not been faked.'); + } + } + + /** + * Définit l'instance de job de file sous-jacente. + */ + public function setJob(JobContract $job): self + { + $this->job = $job; + + return $this; + } +} diff --git a/src/Traits/SerializesAndRestoresModelIdentifiers.php b/src/Traits/SerializesAndRestoresModelIdentifiers.php new file mode 100644 index 0000000..7762aa6 --- /dev/null +++ b/src/Traits/SerializesAndRestoresModelIdentifiers.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Traits; + +use BlitzPHP\Contracts\Queue\QueueableCollection; +use BlitzPHP\Contracts\Queue\QueueableEntity; +use BlitzPHP\Utilities\Iterable\Collection; +use BlitzPHP\Wolke\Builder; +use BlitzPHP\Wolke\Collection as WolkeCollection; +use BlitzPHP\Wolke\Model; +use BlitzPHP\Wolke\Relations\Concerns\AsPivot; +use BlitzPHP\Wolke\Relations\Pivot; +use Illuminate\Contracts\Database\ModelIdentifier; + +/** + * Remplace les entités / collections Wolke par des identifiants lors de la sérialisation, puis les recharge. + */ +trait SerializesAndRestoresModelIdentifiers +{ + /** + * Prépare la valeur de propriété pour la sérialisation. + */ + protected function getSerializedPropertyValue(mixed $value, bool $withRelations = true): mixed + { + if ($value instanceof QueueableCollection) { + return (new ModelIdentifier( + $value->getQueueableClass(), + $value->getQueueableIds(), + $withRelations ? $value->getQueueableRelations() : [], + $value->getQueueableConnection(), + ))->useCollectionClass( + ($collectionClass = $value::class) !== WolkeCollection::class + ? $collectionClass + : null, + ); + } + + if ($value instanceof QueueableEntity) { + return new ModelIdentifier( + $value::class, + $value->getQueueableId(), + $withRelations ? $value->getQueueableRelations() : [], + $value->getQueueableConnection(), + ); + } + + return $value; + } + + /** + * Restaure la valeur de propriété après désérialisation. + */ + protected function getRestoredPropertyValue(mixed $value): mixed + { + if (! $value instanceof ModelIdentifier) { + return $value; + } + + return is_array($value->id) + ? $this->restoreCollection($value) + : $this->restoreModel($value); + } + + /** + * Restaure une collection enfilable. + * + * @param ModelIdentifier $value + * + * @return WolkeCollection + */ + protected function restoreCollection($value) + { + $class = $value->getClass(); + + if (! $class || count($value->id) === 0) { + return null !== ($value->collectionClass ?? null) + ? new $value->collectionClass() + : new WolkeCollection(); + } + + $collection = $this->getQueryForModelRestoration( + (new $class())->setConnection($value->connection), + $value->id, + )->useWritePdo()->get(); + + if (is_a($class, Pivot::class, true) || in_array(AsPivot::class, class_uses($class), true)) { + return $collection; + } + + $collection = $collection->keyBy->getKey(); + + $collectionClass = $collection::class; + + return (new $collectionClass( + (new Collection($value->id)) + ->map(fn ($id) => $collection[$id] ?? null) + ->filter(), + ))->loadMissing($value->relations ?? []); + } + + /** + * Restaure le modèle à partir de son identifiant. + * + * @param ModelIdentifier $value + * + * @return Model + */ + public function restoreModel($value) + { + return $this->getQueryForModelRestoration( + (new ($value->getClass()))->setConnection($value->connection), + $value->id, + )->useWritePdo()->firstOrFail()->loadMissing($value->relations ?? []); + } + + /** + * Retourne la requête de restauration du modèle. + * + * @template TModel of \BlitzPHP\Wolke\Model + * + * @param TModel $model + * + * @return Builder + */ + protected function getQueryForModelRestoration($model, array|int $ids) + { + return $model->newQueryForRestoration($ids); + } +} diff --git a/src/Traits/SerializesModels.php b/src/Traits/SerializesModels.php new file mode 100644 index 0000000..c724461 --- /dev/null +++ b/src/Traits/SerializesModels.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue\Traits; + +use ReflectionClass; +use ReflectionProperty; + +/** + * Sérialise et restaure les propriétés d'un job, y compris les modèles liés. + */ +trait SerializesModels +{ + use SerializesAndRestoresModelIdentifiers; + + /** + * Prépare les valeurs de l'instance pour la sérialisation. + */ + public function __serialize(): array + { + $values = []; + + $reflectionClass = new ReflectionClass($this); + + [$class, $properties, $classLevelWithoutRelations] = [ + static::class, + $reflectionClass->getProperties(), + property_exists($this, 'withoutRelations') && $this->withoutRelations === true, + ]; + + foreach ($properties as $property) { + if ($property->isStatic()) { + continue; + } + + if (! $property->isInitialized($this)) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + $value = $this->getPropertyValue($property); + + if ($property->hasDefaultValue() && $value === $property->getDefaultValue()) { + continue; + } + + $name = $property->getName(); + + if ($property->isPrivate()) { + $name = "\0{$class}\0{$name}"; + } elseif ($property->isProtected()) { + $name = "\0*\0{$name}"; + } + + $values[$name] = $this->getSerializedPropertyValue( + $value, + ! $classLevelWithoutRelations, + ); + } + + return $values; + } + + /** + * Restaure le modèle après désérialisation. + */ + public function __unserialize(array $values): void + { + $properties = (new ReflectionClass($this))->getProperties(); + + $class = static::class; + + foreach ($properties as $property) { + if ($property->isStatic()) { + continue; + } + + $name = $property->getName(); + + if ($property->isPrivate()) { + $name = "\0{$class}\0{$name}"; + } elseif ($property->isProtected()) { + $name = "\0*\0{$name}"; + } + + if (! array_key_exists($name, $values)) { + continue; + } + + $property->setValue( + $this, + $this->getRestoredPropertyValue($values[$name]), + ); + } + } + + /** + * Retourne la valeur de la propriété donnée. + */ + protected function getPropertyValue(ReflectionProperty $property): mixed + { + return $property->getValue($this); + } +} diff --git a/src/Worker.php b/src/Worker.php new file mode 100644 index 0000000..d0ad5cc --- /dev/null +++ b/src/Worker.php @@ -0,0 +1,789 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +use BlitzPHP\Contracts\Cache\CacheInterface; +use BlitzPHP\Contracts\Queue\Job; +use BlitzPHP\Contracts\Queue\Queue; +use BlitzPHP\Queue\DTO\WorkerOptions; +use BlitzPHP\Queue\Enums\WorkerStopReason; +use BlitzPHP\Queue\Events\QueueEventManager; +use BlitzPHP\Queue\Exceptions\MaxAttemptsExceededException; +use BlitzPHP\Queue\Exceptions\TimeoutExceededException; +use BlitzPHP\Utilities\Date; +use Illuminate\Contracts\Debug\ExceptionHandler; +// use BlitzPHP\Database\DetectsLostConnections; // disponible uniquement dans blitz-php/database 1.1 +use Throwable; + +/** + * Worker de file d'attente. + * + * Prélève et exécute les jobs en boucle (daemon) ou un par un, gère les + * timeouts, les tentatives, la mémoire et les signaux POSIX. + */ +class Worker +{ + // use DetectsLostConnections; + + /** + * Code de sortie en cas de succès. + */ + public const EXIT_SUCCESS = EXIT_SUCCESS; + + /** + * Code de sortie en cas d'erreur. + */ + public const EXIT_ERROR = EXIT_ERROR; + + /** + * Code de sortie en cas de dépassement de la limite mémoire. + */ + public const EXIT_MEMORY_LIMIT = 12; + + /** + * Nom du worker. + */ + protected ?string $name; + + /** + * Implémentation du dépôt de cache. + */ + protected CacheInterface $cache; + + /** + * Gestionnaire d'exceptions (contrat Illuminate). + * + * @var ExceptionHandler + */ + protected $exceptions; + + /** + * Callback indiquant si l'application est en maintenance. + * + * @var callable + */ + protected $isDownForMaintenance; + + /** + * Callback de réinitialisation du périmètre applicatif entre deux jobs. + * + * @var callable + */ + protected $resetScope; + + /** + * Indique si le worker doit s'arrêter. + */ + public bool $shouldQuit = false; + + /** + * Indique si le worker a perdu sa connexion. + */ + public bool $lostConnection = false; + + /** + * Indique si le worker est en pause. + */ + public bool $paused = false; + + /** + * Callbacks utilisés pour prélever les jobs. + * + * @var list + */ + protected static array $popCallbacks = []; + + /** + * Code de sortie personnalisé en cas de dépassement mémoire. + */ + public static ?int $memoryExceededExitCode = null; + + /** + * Indique si les exceptions de job doivent être journalisées. + */ + public static bool $reportJobExceptions = true; + + /** + * Indique si le worker doit consulter le signal de redémarrage en cache. + */ + public static bool $restartable = true; + + /** + * Indique si le worker doit consulter le signal de pause en cache. + */ + public static bool $pausable = true; + + /** + * Crée un worker de file d'attente. + * + * @param Manager $manager Instance du gestionnaire de files. + * @param QueueEventManager $events Instance du gestionnaire d'événements de file. + * @param ExceptionHandler $exceptions + */ + public function __construct( + protected Manager $manager, + protected QueueEventManager $events, + // ExceptionHandler $exceptions, + callable $isDownForMaintenance, + ?callable $resetScope = null, + ) { + // $this->exceptions = $exceptions; + $this->isDownForMaintenance = $isDownForMaintenance; + $this->resetScope = $resetScope; + } + + /** + * Écoute la file donnée en boucle (mode daemon). + */ + public function daemon(string $connectionName, string $queue, WorkerOptions $options): int + { + if ($supportsAsyncSignals = $this->supportsAsyncSignals()) { + $this->listenForSignals(); + } + + $lastRestart = $this->getTimestampOfLastQueueRestart(); + + [$startTime, $jobsProcessed] = [hrtime(true) / 1e9, 0]; + + $this->raiseWorkerStartingEvent($connectionName, $queue, $options); + + while (true) { + // Avant de réserver un job, on vérifie que la file n'est pas en pause. + if (! $this->daemonShouldRun($options, $connectionName, $queue)) { + [$status, $reason] = $this->pauseWorker($options, $lastRestart); + + if (null !== $status) { + return $this->stop($status, $options, $reason); + } + + continue; + } + + if (isset($this->resetScope)) { + ($this->resetScope)(); + } + + // Prélèvement du prochain job, enregistrement du timeout, puis exécution. + $job = $this->getNextJob( + $this->manager->driver($connectionName), + $queue, + ); + + if ($supportsAsyncSignals) { + $this->registerTimeoutHandler($job, $options); + } + + // Si un job est disponible, on le traite ; sinon on attend avant de resonder. + if ($job) { + $jobsProcessed++; + + $this->runJob($job, $connectionName, $options); + + if ($options->rest > 0) { + $this->sleep($options->rest); + } + } else { + $this->sleep($options->sleep); + } + + if ($supportsAsyncSignals) { + $this->resetTimeoutHandler(); + } + + // Arrêt si limite mémoire, signal de redémarrage, file vide, max jobs/temps, etc. + [$status, $reason] = $this->stopIfNecessary( + $options, + $lastRestart, + $startTime, + $jobsProcessed, + $job, + ); + + if (null !== $status) { + return $this->stop($status, $options, $reason); + } + } + } + + /** + * Enregistre le gestionnaire de dépassement de délai du worker. + */ + protected function registerTimeoutHandler(Job $job, WorkerOptions $options): void + { + // Gestionnaire SIGALRM : interrompt un job bloqué trop longtemps (signaux async PHP). + pcntl_signal(SIGALRM, function () use ($job, $options) { + if ($job) { + $this->markJobAsFailedIfWillExceedMaxAttempts( + $job->getConnectionName(), + $job, + (int) $options->maxTries, + $e = $this->timeoutExceededException($job), + ); + + $this->markJobAsFailedIfWillExceedMaxExceptions( + $job->getConnectionName(), + $job, + $e, + ); + + $this->markJobAsFailedIfItShouldFailOnTimeout( + $job->getConnectionName(), + $job, + $e, + ); + + $this->events->jobTimeout($job->getConnectionName(), $job->getQueue(), $job); + } + + $this->kill(static::EXIT_ERROR, $options, WorkerStopReason::TimedOut); + }, true); + + pcntl_alarm( + max($this->timeoutForJob($job, $options), 0), + ); + } + + /** + * Réinitialise le gestionnaire de dépassement de délai. + */ + protected function resetTimeoutHandler(): void + { + pcntl_alarm(0); + } + + /** + * Retourne le délai d'exécution applicable au job. + */ + protected function timeoutForJob(Job $job, WorkerOptions $options): int + { + return $job && null !== $job->timeout() ? $job->timeout() : $options->timeout; + } + + /** + * Indique si le daemon doit traiter un job à cette itération. + */ + protected function daemonShouldRun(WorkerOptions $options, string $connectionName, string $queue): bool + { + return ! (($this->isDownForMaintenance)() && ! $options->force) + || $this->paused; + } + + /** + * Met le worker en pause pour l'itération courante. + */ + protected function pauseWorker(WorkerOptions $options, int $lastRestart): ?array + { + $this->sleep($options->sleep > 0 ? $options->sleep : 1); + + return $this->stopIfNecessary($options, $lastRestart); + } + + /** + * Détermine le code de sortie si le processus doit s'arrêter. + */ + protected function stopIfNecessary(WorkerOptions $options, int $lastRestart, float|int $startTime = 0, int $jobsProcessed = 0, mixed $job = null): ?array + { + return match (true) { + $this->lostConnection => [static::EXIT_SUCCESS, WorkerStopReason::LostConnection], + $this->shouldQuit => [static::EXIT_SUCCESS, WorkerStopReason::Interrupted], + $this->memoryExceeded($options->memory) => [static::$memoryExceededExitCode ?? static::EXIT_MEMORY_LIMIT, WorkerStopReason::MaxMemoryExceeded], + $this->queueShouldRestart($lastRestart) => [static::EXIT_SUCCESS, WorkerStopReason::ReceivedRestartSignal], + $options->stopWhenEmpty && null === $job => [static::EXIT_SUCCESS, WorkerStopReason::QueueEmpty], + $options->maxTime && hrtime(true) / 1e9 - $startTime >= $options->maxTime => [static::EXIT_SUCCESS, WorkerStopReason::MaxTimeExceeded], + $options->maxJobs && $jobsProcessed >= $options->maxJobs => [static::EXIT_SUCCESS, WorkerStopReason::MaxJobsExceeded], + default => null, + }; + } + + /** + * Traite le prochain job de la file. + */ + public function runNextJob(string $connectionName, string $queue, WorkerOptions $options): void + { + $job = $this->getNextJob( + $this->manager->connection($connectionName), + $queue, + ); + + // Job disponible : traitement immédiat. File vide : pause puis nouvelle tentative. + if ($job) { + $this->runJob($job, $connectionName, $options); + + return; + } + + $this->sleep($options->sleep); + } + + /** + * Prélève le prochain job via le pilote de file. + */ + protected function getNextJob(Queue $driver, string $queue): ?Job + { + $popJobCallback = fn ($queue, $index = 0) => $driver->pop($queue, $index); + + $this->raiseBeforeJobPopEvent($driver->getConnectionName(), $queue); + + try { + if (isset(static::$popCallbacks[$this->name ?? ''])) { + if (null !== ($job = (static::$popCallbacks[$this->name ?? ''])($popJobCallback, $queue))) { + $this->raiseAfterJobPopEvent($driver->getConnectionName(), $job); + } + + return $job; + } + + foreach (explode(',', $queue) as $index => $queue) { + if ($this->queuePaused($driver->getConnectionName(), $queue)) { + continue; + } + + if (null !== ($job = $popJobCallback($queue, $index))) { + $this->raiseAfterJobPopEvent($driver->getConnectionName(), $job); + + return $job; + } + } + } catch (Throwable $e) { + logger()->error($e->getMessage()); + // $this->exceptions->report($e); + + $this->stopWorkerIfLostConnection($e); + + $this->sleep(1); + } + + return null; + } + + /** + * Indique si la file de la connexion donnée est en pause. + */ + protected function queuePaused(string $connectionName, string $queue): bool + { + if (! static::$pausable) { + return false; + } + + return $this->cache && $this->manager->isPaused($connectionName, $queue); + } + + /** + * Traite le job donné. + */ + protected function runJob(Job $job, string $connectionName, WorkerOptions $options): void + { + try { + $this->process($connectionName, $job, $options); + } catch (Throwable $e) { + if (static::$reportJobExceptions) { + logger()->error($e->getMessage()); + // $this->exceptions->report($e); + } + + $this->stopWorkerIfLostConnection($e); + } + } + + /** + * Arrête le worker si la connexion base de données est perdue. + */ + protected function stopWorkerIfLostConnection(Throwable $e): void + { + /* + if ($this->causedByLostConnection($e)) { + $this->lostConnection = true; + } + */ + } + + /** + * Traite le job prélevé de la file. + * + * @throws Throwable + */ + public function process(string $connectionName, Job $job, WorkerOptions $options): void + { + try { + // Événement « avant job » puis contrôle du nombre maximal de tentatives. + $this->raiseBeforeJobEvent($connectionName, $job); + + $this->markJobAsFailedIfAlreadyExceedsMaxAttempts( + $connectionName, + $job, + (int) $options->maxTries, + ); + + if ($job->isDeleted()) { + $this->raiseAfterJobEvent($connectionName, $job); + + return; + } + + // Exécution du job ; les exceptions sont capturées pour journalisation et relâchement. + $job->fire(); + + $this->raiseAfterJobEvent($connectionName, $job); + } catch (Throwable $e) { + $exceptionOccurred = $e; + + $this->handleJobException($connectionName, $job, $options, $e); + } finally { + $this->events->jobAttempted($connectionName, $job, $exceptionOccurred ?? null); + } + } + + /** + * Traite une exception survenue pendant l'exécution du job. + * + * @throws Throwable + */ + protected function handleJobException(string $connectionName, Job $job, WorkerOptions $options, Throwable $e): void + { + try { + // Marque le job en échec s'il dépassera le quota de tentatives à la prochaine exécution. + if (! $job->hasFailed()) { + $this->markJobAsFailedIfWillExceedMaxAttempts( + $connectionName, + $job, + (int) $options->maxTries, + $e, + ); + + $this->markJobAsFailedIfWillExceedMaxExceptions( + $connectionName, + $job, + $e, + ); + } + + $this->raiseExceptionOccurredJobEvent( + $connectionName, + $job, + $e, + ); + } finally { + // Relâche le job dans la file pour une tentative ultérieure, puis relance l'exception. + if (! $job->isDeleted() && ! $job->isReleased() && ! $job->hasFailed()) { + $backoff = $this->calculateBackoff($job, $options); + + $job->release($backoff); + + $this->events->jobReleasedAfterException($connectionName, $job, $backoff); + } + } + + throw $e; + } + + /** + * Marque le job en échec s'il a dépassé le nombre maximal de tentatives. + * + * Souvent dû à un dépassement de délai lors d'une tentative précédente. + * + * @throws Throwable + */ + protected function markJobAsFailedIfAlreadyExceedsMaxAttempts(string $connectionName, Job $job, int $maxTries): void + { + $maxTries = null !== $job->maxTries() ? $job->maxTries() : $maxTries; + + $retryUntil = $job->retryUntil(); + + if ($retryUntil && Date::now()->getTimestamp() <= $retryUntil) { + return; + } + + if (! $retryUntil && ($maxTries === 0 || $job->attempts() <= $maxTries)) { + return; + } + + $this->failJob($job, $e = $this->maxAttemptsExceededException($job)); + + throw $e; + } + + /** + * Marque le job en échec s'il a dépassé le nombre maximal de tentatives. + */ + protected function markJobAsFailedIfWillExceedMaxAttempts(string $connectionName, Job $job, int $maxTries, Throwable $e): void + { + $maxTries = null !== $job->maxTries() ? $job->maxTries() : $maxTries; + + if ($job->retryUntil() && $job->retryUntil() <= Date::now()->getTimestamp()) { + $this->failJob($job, $e); + } + + if (! $job->retryUntil() && $maxTries > 0 && $job->attempts() >= $maxTries) { + $this->failJob($job, $e); + } + } + + /** + * Marque le job en échec s'il a dépassé le nombre maximal de tentatives. + */ + protected function markJobAsFailedIfWillExceedMaxExceptions(string $connectionName, Job $job, Throwable $e): void + { + if (! $this->cache || null === ($uuid = $job->uuid()) + || null === ($maxExceptions = $job->maxExceptions())) { + return; + } + + if (! $this->cache->get('job-exceptions-' . $uuid)) { + $this->cache->set('job-exceptions-' . $uuid, 0, Date::now()->addDay()->getTimestamp()); + } + + if ($maxExceptions <= $this->cache->increment('job-exceptions-' . $uuid)) { + $this->cache->delete('job-exceptions-' . $uuid); + + $this->failJob($job, $e); + } + } + + /** + * Marque le job en échec s'il doit échouer au timeout. + */ + protected function markJobAsFailedIfItShouldFailOnTimeout(string $connectionName, Job $job, Throwable $e): void + { + if (method_exists($job, 'shouldFailOnTimeout') ? $job->shouldFailOnTimeout() : false) { + $this->failJob($job, $e); + } + } + + /** + * Marque le job en échec et émet l'événement correspondant. + */ + protected function failJob(Job $job, Throwable $e): void + { + $job->fail($e); + } + + /** + * Calcule le délai de retry du job. + */ + protected function calculateBackoff(Job $job, WorkerOptions $options): int + { + $backoff = explode( + ',', + method_exists($job, 'backoff') && null !== $job->backoff() + ? $job->backoff() + : $options->backoff, + ); + + return (int) ($backoff[$job->attempts() - 1] ?? last($backoff)); + } + + /** + * Émet l'événement de démarrage du worker. + */ + protected function raiseWorkerStartingEvent(string $connectionName, string $queue, WorkerOptions $options): void + { + $this->events->workerStarting($connectionName, $queue, $options); + } + + /** + * Émet l'événement de prélèvement imminent. + */ + protected function raiseBeforeJobPopEvent(string $connectionName, ?string $queue = null): void + { + $this->events->jobPopping($connectionName, $queue); + } + + /** + * Émet l'événement de job prélevé. + */ + protected function raiseAfterJobPopEvent(string $connectionName, ?Job $job): void + { + $this->events->jobPopped($connectionName, $job); + } + + /** + * Émet l'événement de traitement en cours. + */ + protected function raiseBeforeJobEvent(string $connectionName, Job $job): void + { + $this->events->jobProcessing($connectionName, $job); + } + + /** + * Émet l'événement de job traité. + */ + protected function raiseAfterJobEvent(string $connectionName, Job $job): void + { + $this->events->jobProcessed($connectionName, $job); + } + + /** + * Émet l'événement d'exception survenue sur un job. + */ + protected function raiseExceptionOccurredJobEvent(string $connectionName, Job $job, Throwable $e): void + { + $this->events->jobExceptionOccured($connectionName, $job, $e); + } + + /** + * Indique si le worker doit redémarrer. + */ + protected function queueShouldRestart(?int $lastRestart): bool + { + if (! static::$restartable) { + return false; + } + + return $this->getTimestampOfLastQueueRestart() !== $lastRestart; + } + + /** + * Retourne l'horodatage du dernier signal de redémarrage, ou null. + */ + protected function getTimestampOfLastQueueRestart(): ?int + { + if (! static::$restartable) { + return null; + } + + if ($this->cache) { + return (int) $this->cache->get('blitzphp-queue-restart'); + } + + return null; + } + + /** + * Active la gestion asynchrone des signaux pour le processus. + */ + protected function listenForSignals(): void + { + pcntl_async_signals(true); + + pcntl_signal(SIGQUIT, fn () => $this->shouldQuit = true); + pcntl_signal(SIGTERM, fn () => $this->shouldQuit = true); + pcntl_signal(SIGINT, fn () => $this->shouldQuit = true); + pcntl_signal(SIGUSR2, fn () => $this->paused = true); + pcntl_signal(SIGCONT, fn () => $this->paused = false); + } + + /** + * Indique si les signaux asynchrones sont disponibles. + */ + protected function supportsAsyncSignals(): bool + { + return extension_loaded('pcntl'); + } + + /** + * Indique si la limite mémoire a été dépassée. + */ + public function memoryExceeded(int $memoryLimit): bool + { + return ((int) $memoryLimit) > 0 && (memory_get_usage(true) / 1024 / 1024) >= ((int) $memoryLimit); + } + + /** + * Arrête l'écoute et quitte le script. + */ + public function stop(int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null): int + { + $this->events->workerStopping($this->manager->getName(), $status, $options, $reason); + + return $status; + } + + /** + * Termine le processus. + */ + public function kill(int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null): never + { + $status = $this->stop($status, $options, $reason); + + if (extension_loaded('posix')) { + posix_kill(getmypid(), SIGKILL); + } + + exit($status); + } + + /** + * Crée une instance de MaxAttemptsExceededException. + */ + protected function maxAttemptsExceededException(Job $job): MaxAttemptsExceededException + { + return MaxAttemptsExceededException::forJob($job); + } + + /** + * Crée une instance de TimeoutExceededException. + */ + protected function timeoutExceededException(Job $job): TimeoutExceededException + { + return TimeoutExceededException::forJob($job); + } + + /** + * Met le script en pause pendant un nombre de secondes donné. + */ + public function sleep(float|int $seconds): void + { + if ($seconds < 1) { + usleep($seconds * 1_000_000); + } else { + sleep($seconds); + } + } + + /** + * Définit l'implémentation du cache. + */ + public function setCache(CacheInterface $cache): self + { + $this->cache = $cache; + + return $this; + } + + /** + * Définit le nom du worker. + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * Enregistre un callback de prélèvement des jobs. + */ + public static function popUsing(string $workerName, callable $callback): void + { + if (null === $callback) { + unset(static::$popCallbacks[$workerName]); + } else { + static::$popCallbacks[$workerName] = $callback; + } + } + + /** + * Retourne le gestionnaire de files. + */ + public function getManager(): Manager + { + return $this->manager; + } + + /** + * Définit le gestionnaire de files. + */ + public function setManager(Manager $manager): void + { + $this->manager = $manager; + } +} diff --git a/src/WorkerOptions.php b/src/WorkerOptions.php new file mode 100644 index 0000000..7822f8b --- /dev/null +++ b/src/WorkerOptions.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Queue; + +/** + * Options d'exécution d'un worker de file d'attente. + * + * Conservé pour compatibilité ; préférez {@see \BlitzPHP\Queue\DTO\WorkerOptions}. + */ +class WorkerOptions +{ + /** + * Crée une instance d'options du worker. + * + * @param string $name Nom du worker (utilisé pour les callbacks de pop personnalisés). + * @param int|list $backoff Secondes d'attente avant de relancer un job ayant levé une exception non gérée. + * @param int $memory Mémoire maximale autorisée (Mo) avant arrêt du worker. + * @param int $timeout Durée maximale d'exécution d'un job enfant (secondes). + * @param int $sleep Secondes d'attente entre deux sondages lorsque la file est vide. + * @param int $maxTries Nombre maximal de tentatives par job. + * @param bool $force Si `true`, le worker tourne même en mode maintenance. + * @param bool $stopWhenEmpty Si `true`, le worker s'arrête dès que la file est vide. + * @param int $maxJobs Nombre maximal de jobs à traiter (0 = illimité). + * @param int $maxTime Durée de vie maximale du worker en secondes (0 = illimitée). + * @param int $rest Secondes de pause entre deux jobs traités avec succès. + */ + public function __construct( + public string $name = 'default', + public array|int $backoff = 0, + public int $memory = 128, + public int $timeout = 60, + public int $sleep = 3, + public int $maxTries = 1, + public bool $force = false, + public bool $stopWhenEmpty = false, + public int $maxJobs = 0, + public int $maxTime = 0, + public $rest = 0, + ) { + } +}