Async-background : un système léger de jobs en arrière-plan pour Ruby sans dépendances externes
Les développeurs d'applications Ruby ont souvent besoin d'exécuter des jobs en arrière-plan, mais les solutions prêtes à l'emploi comme Sidekiq nécessitent une infrastructure supplémentaire. La nouvelle gem async-background offre une alternative qui s'exécute sur Async sans Redis ni Postgres, avec un contrôle fin des ressources.
Pourquoi les solutions existantes étaient insuffisantes
Sidekiq est généralement utilisé pour les jobs en arrière-plan en Ruby, mais il nécessite Redis. Pour les petits projets avec seulement une poignée de jobs, c'est excessif : un conteneur supplémentaire dans docker-compose, un autre point de défaillance, et des coûts d'infrastructure. De plus, Sidekiq ne permet pas une distribution flexible de la charge entre les workers — un rapport nocturne lourd peut ralentir le traitement des e-mails des utilisateurs.
Les autres options ont aussi leurs inconvénients :
whenever— génère un crontab système, sans file d'attente ni intégration à l'app.rufus-scheduler— s'exécute dans des threads séparés, causant des changements de contexte et des problèmes de duplication dans les apps Async avec plusieurs processus.good_jobetsolid_queue— liés à ActiveRecord et Postgres. Cela crée des risques : les jobs en arrière-plan concurrencent la charge transactionnelle pour les ressources de la base de données, potentiellement ralentissant l'app principale.
Ainsi, il manquait une solution combinant plannings cron, intervalles et file d'attente dynamique sans dépendances externes, avec un contrôle sur l'utilisation des ressources.
Principes de conception clés
L'auteur de la gem a défini six principes fondamentaux :
- Boucle d'événements unique. Le scheduler s'exécute dans la même boucle réactive que l'app, sans threads séparés.
- Zéro infrastructure. La file est stockée sur disque (SQLite en mode WAL ou un fichier dans
tmp/), pas de Redis ni Postgres. - Dépendances optionnelles. Les fonctionnalités supplémentaires (comme les métriques) sont ajoutées via des gems optionnelles.
- Sécurité multi-processus sans coordinateur. Assure qu'un job s'exécute dans un seul processus lors de l'utilisation de Falcon (forks).
- Persistance comme assurance, vitesse comme mécanisme séparé. La fiabilité vient du stockage SQLite, la vitesse des réveils via socket UNIX.
- Utilisation gérée des workers. Les développeurs peuvent assigner des jobs à des workers spécifiques via variables d'environnement et config YAML.
Ces principes ont permis de créer une gem qui s'intègre parfaitement dans les apps Async existantes sans infrastructure supplémentaire.
Fonctionnalités architecturales
MinHeap au lieu de ticks de polling
Au lieu d'une boucle avec sleep 1 (réveil toutes les secondes pour vérifier tous les jobs), un tas minimum binaire (MinHeap) est utilisé. Cela permet :
- Récupération du prochain job en O(1) avec
peek. - Mise à jour du tas après exécution d'un job en O(log n) avec
replace_top. - Sommeil précis jusqu'au prochain événement, économisant les ressources CPU.
L'implémentation ne fait que 74 lignes et n'a pas de dépendances externes.
Deux échelles de temps
Pour les jobs basés sur intervalles (ex. every: 60), des horloges monotones (CLOCK_MONOTONIC) sont utilisées pour éviter les problèmes de synchronisation horaire (NTP). Pour les jobs cron (ex. cron: "0 3 *"), l'heure murale (Time.now) est utilisée car le planning est lié au temps calendaire.
En code, cela donne :
next_run_at = if task_config[:interval]
now + jitter + task_config[:interval]
else
now_wall = Time.now
wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
now + jitter + [wall_wait, MIN_SLEEP_TIME].max
end
Sharding via CRC32
Lors de l'utilisation de Falcon (qui fork), les jobs doivent s'exécuter dans un seul processus. Le sharding déterministe y pourvoit : Zlib.crc32(name) % total_workers + 1 est calculé pour le nom du job. S'il correspond à l'index du worker courant, le job est chargé.
assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
next unless assigned == worker_index
Cela élimine le besoin de verrous distribués.
Skip-on-Overlap
Si un job dure plus longtemps que son intervalle (ex. 90 secondes pour un intervalle de 60), la nouvelle instance est sautée. Au lieu de cela, le job courant est journalisé, sauté, et replannifié pour le prochain créneau :
if entry.running
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
metrics.job_skipped(entry)
entry.reschedule(monotonic_now)
heap.replace_top(entry)
next
end
File d'attente : SQLite + Socket UNIX
La file dynamique utilise deux mécanismes :
- SQLite — source de vérité. Les jobs sont persistés sur disque pour la fiabilité.
- Socket UNIX — pour notifications instantanées. Après ajout d'un job en base, un signal est envoyé via le socket pour que le worker le traite immédiatement.
Le polling toutes les 5 secondes sert de secours en cas de perte de signal.
Récupération des pannes
Si un worker plante en plein job, le statut reste "running". Au redémarrage, Queue::Store#recover trouve ces enregistrements et les remet dans la file.
Points clés à retenir
- Pas de dépendances sur services externes. Async-background repose uniquement sur le système de fichiers, simplifiant le déploiement.
- Contrôle des ressources. Isoler les workers pour différents types de jobs (cron, file) prévient la contention des ressources.
- Fiabilité. Les jobs ne sont pas perdus grâce à la persistance SQLite et aux mécanismes de récupération.
- Flexibilité. Config YAML et variables d'environnement facilitent l'ajustement de la distribution des jobs.
- Performance. Min-heap et sockets UNIX offrent faible latence et utilisation efficace du CPU.
— Editorial Team
Aucun commentaire pour le moment.