Introduction à Solid Queue et son fonctionnement
Solid Queue ? C’est quoi ?
Rails 8 sortira bientôt et parmi ses fonctionnalités, on peut noter l’intégration par défaut de Solid Queue pour pouvoir gérer nos jobs asynchrones. Mais qu’est-ce que c’est Solid Queue ?
Annoncé au Rails World 2023, Solid Queue est un backend pouvant utiliser la base de données SQL de notre choix (MySQL, SQLite, PostgreSQL) pour gérer nos jobs, contrairement à Sidekiq ou Resque qui ne s’appuient que sur Redis.
L’idée derrière est de réduire au maximum la dépendance à de multiples services comme Redis pour se simplifier la vie et se concentrer sur l’essentiel. Redis est peut-être plus performant, mais est-ce que la différence mérite vraiment l’effort de configuration et maintenance d’un service en plus ? Pour une majorité de projets, les performances d’une base de données seront amplement suffisantes.
De plus, grâce à l’implémentation du SELECT ... FOR UPDATE SKIP LOCKED
dans nos bases de données, l’utilisation de multiples workers exécutant nos jobs est possible. En effet le SKIP LOCKED
passera outre des lignes des tables déjà utilisé par les autres workers sans être bloqué ni se marcher dessus.
Une petite démo ?
Faisons un petit projet rails avec un simple job. Je serai sur Rails 7.2 pour présenter la configuration de base, mais sachez que la gem et la configuration de base seront déjà prêtes sur Rails 8
rails new solidqueue-demo --database=postgresql
cd solidqueue-demo
bin/bundle add solid_queue
bin/rails solid_queue:install
La tâche install
de la gem va générer les YML de configurations, les migrations sur la base de données et rajouter dans production.rb
Solid Queue en tant qu’adapter pour ActiveJobs via deux lignes.
Répliquons les deux lignes dans config/environments/development.rb
pour activer Solid Queue dans notre environnement de developement et ajoutons des logs.
# config/environments/development.rb
config.solid_queue.logger = Logger.new(STDOUT)
config.active_job.queue_adapter = :solid_queue
# indique quelle base de données utiliser pour queue
config.solid_queue.connects_to = { database: { writing: :queue } }
Ajoutons ensuite la base de données pour queue dans config/database.yml
# config/database.yml
development:
primary:
<<: *default
database: solidqueue_demo_development
queue:
<<: *default
database: solidqueue_demo_development_queue
migrations_paths: db/queue_migrate
Il est fortement recommandé d’utiliser une base de données séparée de celle de l’application. Vous pouvez alors supprimer le connects_to
et déplacer le contenu de db/queue_schema.rb
dans une migration si vous souhaitez partir sur du tout-en-un
Il ne reste plus qu’à créer nos bases de données avec les schémas de Solid Queue
bin/rails db:prepare
Et voilà nous sommes prêts à envoyer nos jobs ! Faisons un job bateau pour tester
# app/jobs/bonjour_job.rb
class BonjourJob < ApplicationJob
def perform(a)
puts "Bonjour #{a} :)"
end
end
et allons dans la console. Activons les logs de debug pour voir plus clairement ce qu’il va se passer puis mettons un job en queue
Rails.logger = Logger.new(STDOUT)
BonjourJob.perform_later("vous")
Grâce aux logs, nous pouvons alors voir ceci :
Tout d’abord, une ligne sera créée dans solid_queue_jobs
avec le nom du jobs, ses arguments, sa queue (ici default
car non définie) et des timestamps. Puis ensuite une deuxième ligne dans une autre table : solid_queue_ready_executions
référençant notre première ligne. Nous verrons plus tard leur utilité
Faisons la même chose, mais avec une date future :
BonjourJob.set(wait: 2.days).perform_later("nous")
La grosse différence étant la deuxième ligne créée qui ne sera plus sur solid_queue_ready_executions
, mais solid_queue_scheduled_executions
Maintenant que les jobs sont mis dans la queue, il est temps de lancer les process de Solid Queue qui vont les exécuter. Il y a deux possibilités :
- Utiliser le puma de notre serveur pour exécuter nos jobs
- Lancer un process à part (lançable avec
bin/jobs
)
Ici, nous allons utiliser le process à part, mais retenez que si vous préférez utiliser puma, il vous suffit de rajouter un plugin puma comme ceci
# config/puma.rb
plugin :solid_queue
Lançons Solid Queue avec bin/jobs
:
Notre premier job a été récupéré par le worker (process_id
: 3) et executé.
Mais comment ça marche ?
Les process
Tout d’abord, parlons de ce qui a été exécuté au lancement de Solid Queue. Sur le screen plus haut on peut voir que 3 process ont démarré: Le Supervisor, le Dispatcher et le Worker :
- le Dispatcher va sélectionner les jobs demandant d’être exécuté dans l’instant et les mettre à disposition pour exécution. C’est aussi lui qui va gérer l’exécution en simultanée ou non de certains jobs (Exemple: on veut que tout les jobs de types import ne s’exécutent qu’un à la fois, le dispatcher va s’assurer de cette règle)
- le Worker va récupérer les jobs mis à disposition par le dispatcher et les exécuter. En fonction du résultat, il va écrire dans différentes tables
- le Supervisor va gérer les deux premiers process et en créer/détruire si besoin et selon la configuration de Solid Queue
Les dispatchers et les workers sont tous deux configurables dans config/queue.yml
en fonction de l’environnement. Voilà la configuration de base
# config/queue.yml
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default
polling_interval
indique à quel intervalle les dispatchers et workers vont ping la base de données en secondes pour surveiller les queues (les workers devant prendre en charge les jobs le plus vite possible, l’intervalle est donc très bas pour eux)
queues
va définir les différentes queues (des namespaces pour les jobs en somme) possible pour les jobs. Leurs ordres définissant l’ordre de priorité auquel ses jobs seront traités (exemple queues: [import, default]
, les jobs d’import auront priorité par rapport à tout les autres jobs). La wildcard indique simplement toutes les autres queues possibles
batch_size
indique le nombre de jobs que le dispatcher va traiter en un coup
threads
et processes
permettent simplement d’ajuster le nombre de workers et threads qui seront exécutés par Solid Queue
Cela vous permettra d’ajuster ces process en fonction de votre environnement.
Les tables
Lors de la mise en queue de nos jobs, nous avons pu voir de la lecture et écriture sur plusieurs tables, vous pouvez voir ces tables dans votre schema de queue ou directement dans votre base de données. Listons et expliquons l’utilité de ces différentes tables:
- solid_queue_blocked_executions
- solid_queue_claimed_executions
- solid_queue_failed_executions
- solid_queue_jobs
- solid_queue_pauses
- solid_queue_processes
- solid_queue_ready_executions
- solid_queue_recurring_executions
- solid_queue_recurring_tasks
- solid_queue_scheduled_executions
- solid_queue_semaphores
Les jobs seront stockés dans solid_queue_jobs et référencés dans la plupart des autres tables. Une fois terminé, le job sera conservé dans cette table avec une date de fin (finished_at
). En regardant en base, on peut voir 2 lignes dans cette table : nos deux jobs, dont un terminé !
Pour savoir quand les jobs doivent être lancés, une ligne dans solid_queue_scheduled_executions
ou solid_queue_recurring_tasks
(si un CRON) est créée. Lors de la création de notre deuxième job, une ligne a été créée dans solid_queue_scheduled_executions
pour indiquer sa date d’exécution.
Le Dispatcher va alors récupérer les jobs disponibles dans ces deux tables et les mettre à disposition dans solid_queue_ready_executions
pour les Workers qui les exécuteront. Dans le cas de notre premier job, une ligne dans solid_queue_ready_executions
a directement été créé, car aucune attente n’a été spécifiée
Les workers vont alors piocher parmi ces jobs en fonction de leur priorité et créer une ligne dans solid_queue_claimed_executions
pour indiquer quels process a pris quel jobs et à quel moment. Vous remarquerez que la table solid_queue_claimed_executions
référence solid_queue_processes
qui est tout simplement la liste de tous les process lancés par SolidQueue.
Une fois terminé, une date de fin sera ajoutée dans solid_queue_jobs
si le job s’est bien déroulé ou bien dans solid_queue_failed_executions
le cas échéant. À savoir que les jobs ne sont pas automatiquement relancés si échoués, vous devez le préciser à ActiveJob si besoin.
solid_queue_pauses
nous permet de mettre en pause des queues entière si besoin (en cas de bug présent dans certains jobs par exemple pour éviter d’aggraver une situation)
solid_queue_semaphores
et solid_queue_blocked_executions
permettent quant à eux de gérer l’exécution en concurrence des jobs et aussi d’éviter que plusieurs jobs d’un même type soient lancés en même temps. Les jobs en attente de l’execution d’autres jobs étant placés dans solid_queue_blocked_executions
et solid_queue_semaphores
permettant d’indiquer la disponibilité d’exécution.
Et pour finir, solid_queue_recurring_executions
qui sert dans le cas d’utilisation de plusieurs scheduler pour vos CRON. Cela indique si un CRON a déjà été pris en charge (et donc évitez les CRON en doublon)
Les CRON
Un autre avantage de Solid Queue c’est qu’il est tout en un, pas besoin de rajouter x plugins pour avoir accès aux CRON par exemple.
Vous pouvez configurer vos CRON dans config/recurring.yml
, tout sera géré ensuite par le dispatcher.
Rajoutons un CRON pour notre job:
# config/recurring.yml
development:
bonjour:
class: BonjourJob
args: ['la minute']
schedule: every minute
On peut aussi exécuter du code au lieu d’un job
# config/recurring.yml
au-revoir:
command: "puts 'au revoir :('"
schedule: every minute
Et comment on monitore tout ça ?
C’est bien beau de tout avoir dans sa base de données, mais cela pourrait manquer de visibilité pour certains.
Ça tombe bien, une gem est sorti peu après Solid Queue pour avoir un dashboard de Solid Queue: mission_control-jobs
Installons-la:
bundle add mission_control-jobs
et rajoutons l’endpoint du dashboard dans notre config/routes.rb
# config/routes.rb
Rails.application.routes.draw do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
Vous pouvez ensuite rajouter votre ApplicationController dans config/application.rb
pour pouvoir ajouter votre système d’authentification préféré sur l’endpoint (ne laissez évidemment pas cet endpoint sans authentification)
# config/application.rb
config.mission_control.jobs.base_controller_class = "VotreSuperbeController"
Et vous voilà avec un super dashboard:
Vous pourrez mettre en pause les queues, relancer les jobs échoués, les supprimer et voir les différents workers en cours d’exécution !
Vous pourrez d’ailleurs voir nos 2 précédents jobs, dont celui qui est en attente pour dans 2 jours. En cliquant dessus, vous aurez accès aux données des différentes colonnes du job dont les arguments que vous avez envoyé.
Les CRON seront comme vous vous en doutez dans Recurring tasks
Conclusion
Et voilà, nous avons maintenant nos jobs qui tournent en asynchrone avec des CRONs. Tout ça dans notre base de données et sans dépendre d’un Redis.
La simplicité de cette gem est vraiment satisfaisante et permet d’aller droit au but sans se contraindre à mettre en place une infra plus compliquée que nécessaire. L’axe de Rails 8 est de se débarrasser du superflu avec SolidQueue, mais aussi SolidCache et SolidCable, se basant sur le même principe de tout centraliser dans sa base de données.
Si vous voulez pousser la chose plus loin, n’hésitez pas à faire un tour sur le github de SolidQueue
À bientôt 🙂 !