Les tâches en arrière-plan sans le poids des dépendances
Cet article présente notre exploration initiale de Gleam pour Curling IO v3. Nous avons finalement choisi Rust et TypeScript. Pourquoi nous avons choisi Rust pour la version 3 explique cette décision et ce que nous avons conservé de cette exploration.
Cette note technique d’implémentation vient du développement de Curling IO v3. Elle s’adresse aux ingénieurs logiciels et aborde Gleam, la BEAM, OTP, les files d’attente dans SQLite, la durabilité et l’exécution des tâches plus en détail que nos articles habituels sur le produit.
Lorsqu’un curleur demande un courriel de connexion, la page devrait répondre immédiatement. Il ne devrait pas attendre Postmark, et nous ne devrions pas exploiter un parc de processus de travail distincts simplement pour envoyer un message en arrière-plan.
Curling IO version 3 exécute les tâches en arrière-plan dans le même environnement BEAM que l’application web. Sans Redis, processus de travail distinct ou déploiement supplémentaire. La file d’attente est durable, car les tâches en attente sont stockées dans SQLite plutôt qu’en mémoire.
L’avantage de BEAM
La machine virtuelle BEAM a été conçue pour les systèmes de télécommunications qui devaient gérer des millions d’opérations simultanées sans interruption. Chaque application BEAM possède déjà des processus légers, des superviseurs et des échanges de messages intégrés. Ce ne sont pas des fils d’exécution du système d’exploitation. L’ordonnanceur de la machine virtuelle les gère, et des centaines de milliers peuvent fonctionner dans un seul processus du système d’exploitation.
Un « processus de travail en arrière-plan » n’est donc pas un service distinct. C’est simplement un autre processus qui fonctionne aux côtés des gestionnaires HTTP, dans le même environnement d’exécution et avec les mêmes journaux. Le démarrer est un appel de fonction, pas un déploiement.
Pourquoi ne pas simplement lancer les tâches et les oublier?
Sur BEAM, le plus simple serait de créer un processus pour chaque tâche et de le laisser fonctionner, ou d’utiliser un acteur OTP avec des messages en mémoire. Nous y avons pensé, mais il manque quelque chose : si le serveur redémarre, toutes les tâches en attente disparaissent. Perdre un courriel de connexion est mineur, car l’utilisateur peut en demander un autre. Mais nous exécuterons aussi la génération d’horaires de séances, le traitement de paiements par Stripe et les synchronisations comptables. Les perdre en cours de route serait un vrai problème.
Nous voulions de la durabilité sans complexité. SQLite était déjà là.
Une base de données distincte
Curling IO utilise déjà SQLite pour ses bases de données propres à chaque sport, une par sport. Nous aurions pu y ajouter une table jobs, mais nous l’avons délibérément placée dans une base distincte, shared.db. Le traitement des tâches entraîne des écritures fréquentes : insertion, passage à l’état en cours, puis à l’état terminé. Il n’y a aucune raison de se disputer les verrous d’écriture des bases des sports. SQLite utilise un journal d’écriture anticipée avec un seul rédacteur. Séparer les charges de travail permet donc au traitement des tâches de ne jamais bloquer une requête d’inscription, et vice versa.
La table des tâches :
Schéma de la table des tâches
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
max_running_seconds INTEGER NOT NULL DEFAULT 120,
error TEXT,
created_at INTEGER NOT NULL,
run_at INTEGER NOT NULL,
started_at INTEGER,
completed_at INTEGER
) STRICT;
La mise en file est un simple INSERT
Depuis un gestionnaire de requête, envoyer un courriel en arrière-plan ressemble à ceci :
Exemple de mise en file
jobs.enqueue_email(request_context.shared_db, request_context.postmark_api_token, email)
En interne, il s’agit d’un seul INSERT INTO jobs, avec les détails du courriel sérialisés en JSON dans la colonne payload. L’appel se termine en quelques microsecondes. L’utilisateur reçoit sa réponse immédiatement au lieu d’attendre les 100 à 500 ms de l’appel à l’API Postmark.
Le processus de travail : un acteur OTP
Le processus de travail est un seul acteur OTP, l’abstraction BEAM d’un processus avec état qui traite les messages un à la fois. Le nôtre a exactement un type de message :
Boucle de traitement
pub type Message {
CheckForWork
}
Au démarrage, l’acteur s’envoie un message CheckForWork après un délai d’une seconde. Lorsqu’il traite ce message, il cherche les tâches en attente, les traite et planifie la prochaine vérification. Cette boucle d’interrogation s’entretient elle-même :
fn handle_message(state, msg) {
case msg {
CheckForWork -> {
process_pending_jobs(state)
process.send_after(state.self, 1000, CheckForWork)
actor.continue(state)
}
}
}
Chaque interrogation récupère un lot de tâches en attente dont la date run_at est passée, les traite une à la fois, puis les marque comme terminées ou échouées. Une tâche défectueuse ne fait jamais planter l’acteur. Les erreurs sont interceptées et consignées, et l’acteur continue le reste du lot.
Réessayer avec un délai exponentiel
Tous les échecs ne sont pas permanents. Postmark peut être temporairement inaccessible ou retourner une erreur 500. La logique de nouvelle tentative est simple : si une tâche échoue et qu’il reste des tentatives, elle repasse à pending avec une date run_at future. Le délai augmente de façon exponentielle : 5 secondes, 25 secondes, 125 secondes.
Délais des nouvelles tentatives et requête des tâches échouées
Attempt 1 fails → retry in 5s
Attempt 2 fails → retry in 25s
Attempt 3 fails → permanently failed
Le nombre par défaut est de trois tentatives, configurable par tâche dans la colonne max_attempts. Une tâche définitivement échouée reste dans la table avec son message d’erreur. Vous pouvez donc voir exactement ce qui s’est passé :
SELECT id, kind, error, attempts FROM jobs WHERE status = 'failed';
Aucun tableau de bord de surveillance distinct n’est nécessaire. Une requête SQL suffit.
Une supervision pour résister aux pannes
L’acteur fonctionne sous un superviseur OTP. Si son processus meurt, le superviseur le redémarre automatiquement avec le même nom enregistré. La boucle d’interrogation reprend, et un passage de nettoyage récupère les tâches qui étaient marquées running au moment de la panne.
Chaque tâche possède une colonne max_running_seconds, avec une valeur par défaut de 120 secondes. Au début de chaque cycle, l’acteur cherche les tâches dont l’état running dépasse leur délai et les remet à pending. Comme le compteur attempts a déjà été incrémenté au passage à running, la logique existante de nouvelles tentatives et de délais gère la suite, y compris la limite max_attempts. Les tâches terminées et échouées sont supprimées après 7 jours, ce qui laisse amplement le temps de diagnostiquer les problèmes.
C’est le terrain de prédilection de BEAM. Avec la philosophie Erlang « let it crash », vous n’écrivez pas du code défensif pour empêcher toutes les pannes possibles. Vous écrivez un superviseur qui permet de s’en remettre et un passage de nettoyage qui récupère ce qui échappe au reste. Le résultat : moins de code et plus de résistance aux pannes.
L’architecture complète
Voici ce qui se passe lorsqu’un utilisateur demande un courriel de connexion :
- Le gestionnaire de requête valide l’adresse, génère un jeton et appelle
jobs.enqueue_email(), soit un simple INSERT dansshared.db, en quelques microsecondes. - La réponse retourne immédiatement la page qui demande de vérifier ses courriels.
- L’acteur des tâches récupère la tâche en attente à sa prochaine interrogation, dans la seconde qui suit.
- L’acteur marque la tâche
running, appelle l’API Postmark, puis la marquecompleted. - Si Postmark est en panne, la tâche retourne à
pendingavec un délai avant la prochaine tentative.
Ce dont nous n’avons pas eu besoin
Cette configuration se passe des éléments suivants :
- Aucun Redis. La file d’attente est une table SQLite.
- Aucun processus de travail distinct. L’acteur fonctionne dans la même machine virtuelle BEAM.
- Aucun cadriciel de tâches. Quelques centaines de lignes de Gleam suffisent.
- Aucun service de surveillance. Les tâches échouées sont des lignes de table.
- Aucune coordination de déploiement. Un seul exécutable, un seul processus.
Nous pensons que cette conception peut accueillir des dizaines de milliers de clubs, d’après l’utilisation de la version 2 et les résultats publiés des bancs d’essai de profils de requêtes en base de données. C’est une extrapolation, pas une limite du système de tâches démontrée par un test de charge. La capacité dépend du rythme d’arrivée des tâches, de la durée des transactions et de la quantité de travail en attente aux périodes de pointe. Les bancs d’essai mesurent de courts parcours en base de données. Ils ne mesurent ni les limites des fournisseurs de courriel ni une charge complète de tâches en arrière-plan.
La suite
Le système de tâches est conçu pour évoluer. Ajouter un type de tâche revient à ajouter une chaîne kind et une fonction de traitement. La production de longs rapports, les importations de données et les campagnes de courriel suivent toutes le même modèle : insérer une ligne et laisser l’acteur la récupérer.
Cet article est le quatrième de la série sur les fondations de Curling IO. À suivre : pourquoi nous avons choisi SQLite.