L’infrastructure des tâches dans Django¶
Pour des versions plus anciennes de Django, le rétroportage django-tasks est disponible.
Pour une application web, il y a souvent du code à exécuter en plus du processus de transformation des requêtes HTTP en réponses HTTP. Pour certaines fonctionnalités, il peut être avantageux d’exécuter du code en dehors du cycle de requête-réponse.
C’est là qu’entrent en jeu les tâches d’arrière-plan.
Les tâches d’arrière-plan peuvent décharger le cycle de requête-réponse pour exécuter du code en dehors de ce cycle, potentiellement à une date ultérieure. Cela permet de garder les requêtes rapides, de réduire la latence et d’amélioration le ressenti des utilisateurs. Par exemple, une utilisatrice ne devrait pas avoir besoin d’attendre qu’un courriel soit envoyé avant que sa page termine de charger.
L’infrastructure des tâches de Django facilite la définition et la mise en file d’attente de ce genre de travail. Mais elle ne fournit pas de mécanisme d’exécution de ces tâches. L’exécution réelle doit être gérée par une infrastructure extérieure à Django, telle qu’un processus ou service séparé. À ce titre, un moteur de tâches capable de les exécuter sur ce service externe doit être choisi et configuré.
Notions de base sur les tâches d’arrière-plan¶
Lorsque du code doit être exécuté en arrière-plan, Django crée une tâche Task, qui est stockée dans une file d’attente. Cette tâche contient toutes les métadonnées utiles à son exécution, de même qu’un identifiant unique pour que Django puisse plus tard obtenir son résultat.
Un processus de travail ira piocher de nouvelles tâches à exécuter dans la file d’attente. Lorsqu’une nouvelle tâches est ajoutée, le processus réserve la tâche, l’exécute, et enregistre son statut et son résultat dans la file d’attente. Ces processus s’exécutent en dehors du cycle de requête-réponse.
Configuration d’un moteur de tâches¶
Le moteur de tâches détermine comment et où les tâches sont stockées en vue de leur exécution et comment elles sont exécutées. Différents moteurs de tâches possèdent différentes caractéristiques et options de configuration, ce qui peut influer sur la performance et la fiabilité de votre application. Django est livré avec des moteurs intégrés, mais ceux-ci sont uniquement pour du développement ou des tests.
Django gère la définition des tâches, leur validation, mise en file d’attente, et gestion de leurs résultats, pas leur exécution, donc les configurations de production ont besoin d’un moteur ou d’un processus de lancement qui exécute réellement ce qui se trouve en attente. Les options possibles sont énumérées sur la page Community Ecosystem.
Les moteurs de tâches sont configurés par le réglage TASKS de votre fichier de réglages. Même si la majorité des applications n’auront besoin que d’un seul moteur, il est possible d’en avoir plusieurs.
Exécution immédiate¶
Il s’agit du moteur par défaut si aucun autre n’a été indiqué dans le fichier des réglages. Ce moteur ImmediateBackend exécute immédiatement les tâches en file d’attente, au lieu de le faire en arrière-plan. Cela permet notamment d’ajouter progressivement la fonctionnalité des tâches à une application, avant que l’infrastructure nécessaire ne soit mise en place.
Pour l’utiliser, définissez BACKEND à "django.tasks.backends.immediate.ImmediateBackend":
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
Le moteur ImmediateBackend peut aussi être utile pour les tests, afin d’éviter d’avoir à exécuter un processus d’exécution externe au lancement des tests.
Le moteur bidon¶
Le moteur DummyBackend n’exécute pas du tout les tâches en liste d’attente, elle ne fait que stocker leur résultat en vue d’une utilisation ultérieure. Ces résultats de tâches restent toujours dans l’état READY (prête).
Ce moteur n’est pas conçu pour être utilisé en production, il n’est fourni que par commodité et destiné à être utilisé durant le développement et les tests.
Pour l’utiliser, définissez BACKEND à "django.tasks.backends.dummy.DummyBackend":
TASKS = {"default": {"BACKEND": "django.tasks.backends.dummy.DummyBackend"}}
Les résultats des tâches en file d’attente peuvent être obtenus à partir de l’attribut results:
>>> from django.tasks import default_task_backend
>>> my_task.enqueue()
>>> len(default_task_backend.results)
1
Les résultats stockés peuvent être effacés en utilisant la méthode clear():
>>> default_task_backend.clear()
>>> len(default_task_backend.results)
0
Moteurs de tierce partie¶
As mentioned at the beginning of this section, Django includes backends suitable for development and testing only. Production systems should rely on backends that supply a worker process and a durable queue implementation. Available third-party backends are listed on the Community Ecosystem page and the Tasks framework grid from Django Packages.
Pour utiliser un moteur de tâches externe avec Django, indiquez son chemin d’importation Python dans la clé setting:BACKEND <TASKS-BACKEND> du réglage setting:TASKS, comme ceci :
TASKS = {
"default": {
"BACKEND": "path.to.backend",
}
}
Un moteur de tâches est une classe héritant de BaseTaskBackend. Au minimum, elle doit implémenter BaseTaskBackend.enqueue(). Si vous construisez votre propre moteur, vous pouvez utiliser les moteurs de tâches intégrés comme référence d’implémentation. Vous trouverez le code dans le répertoire django/tasks/backends/ du code source de Django.
Gestion du code asynchrone¶
La prise en charge des moteurs de tâches asynchrones est en développement.
BaseTaskBackend possède des variantes asnychrones de toutes les méthodes de base. Par convention, les versions asynchrones de toutes les méthodes sont préfixées par a. Les arguments des deux variantes sont identiques.
Obtention des moteurs¶
Les moteurs peuvent être obtenus en utilisant le gestionnaire de connexion task_backends:
from django.tasks import task_backends
task_backends["default"] # The default backend
task_backends["reserve"] # Another backend
Le moteur par défaut est disponible en tant que default_task_backend:
from django.tasks import default_task_backend
Définition des tâches¶
Les tâches sont définies en utilisant le décorateur django.tasks.task() sur une fonction au niveau d’un module :
from django.core.mail import send_mail
from django.tasks import task
@task
def email_users(emails, subject, message):
return send_mail(
subject=subject, message=message, from_email=None, recipient_list=emails
)
La valeur renvoyée par le décorateur est une instance Task.
Les attributs de Task peuvent être personnalisés via les paramètres du décorateur @task:
from django.core.mail import send_mail
from django.tasks import task
@task(priority=2, queue_name="emails")
def email_users(emails, subject, message):
return send_mail(
subject=subject, message=message, from_email=None, recipient_list=emails
)
Par convention, les tâches sont définies dans un fichier tasks.py, mais ceci n’est pas obligatoire.
Contexte des tâches¶
Parfois, la tâche à exécuter peut avoir besoin de connaître le contexte dans lequel elle a été mise en file d’attente, et comment elle est exécutée. Elle peut pour cela accepter un argument context qui est une instance de TaskContext.
Pour recevoir le contexte de la tâche comme argument d’une fonction de tâche, passez takes_context au moment de sa définition :
import logging
from django.core.mail import send_mail
from django.tasks import task
logger = logging.getLogger(__name__)
@task(takes_context=True)
def email_users(context, emails, subject, message):
logger.debug(
f"Attempt {context.attempt} to send user email. Task result id: {context.task_result.id}."
)
return send_mail(
subject=subject, message=message, from_email=None, recipient_list=emails
)
Modification des tâches¶
Avant de mettre une tâche en file d’attente, il peut être nécessaire de modifier certains paramètres de la tâche. Par exemple, pour lui donner une priorité plus haute que la normale.
Une instance Task ne peut pas être modifiée directement. Mais on peut par contre créer une instance modifiée avec la méthode using(), l’originale restant intacte. Par exemple :
>>> email_users.priority
0
>>> email_users.using(priority=10).priority
10
Mise en file d’attente des tâches¶
Pour ajouter une tâche dans le stockage de la file d’attente en vue de son exécution, appelez la méthode enqueue() sur elle. Si la tâche accepte des arguments, ils peuvent être transmis tels quels. Par exemple :
result = email_users.enqueue(
emails=["user@example.com"],
subject="You have a message",
message="Hello there!",
)
Cela renvoie un objet TaskResult, qui peut être utilisé ensuite pour récupérer le résultat de la tâche après qu’elle a terminé son exécution.
Pour mettre en file d’attente des tâches dans un contexte asynchrone, aenqueue() est disponible comme variante async de enqueue().
Comme les arguments et les valeurs de renvoi des tâches sont toutes sérialisées en JSON, elles doivent pouvoir être sérialisées en JSON :
>>> process_data.enqueue(datetime.now())
Traceback (most recent call last):
...
TypeError: Object of type datetime is not JSON serializable
Les arguments doivent aussi être capable de faire un cycle aller-retour par json.dumps()/ json.loads() sans changer de type. Par exemple, considérez cette tâche :
@task()
def double_dictionary(key):
return {key: key * 2}
Si le moteur ImmediateBackend a été configuré comme moteur par défaut :
>>> result = double_dictionary.enqueue((1, 2, 3))
>>> result.status
FAILED
>>> result.errors[0].traceback
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
La tâche double_dictionary échoue parce qu’après l’aller-retour JSON, le tuple (1, 2, 3) devient la liste [1, 2, 3], qui ne peut pas être utilisée comme clé de dictionnaire.
En général, les objets complexes tels que des instances de modèles ou des types Python comme datetime ou tuple ne peuvent pas être utilisés dans le système des tâches sans une conversion spécifique.
Transactions¶
Pour la plupart des moteurs, les tâches sont exécutées dans un processus séparé, utilisant une connexion de base de données différente. Lorsqu’on utilise une transaction, sans attendre que celle-ci soit commitée, des processus d’exécution pourraient démarrer le traitement d’une tâche utilisant des objets auxquels elle ne peut pas encore accéder.
Par exemple, considérons cet exemple simplifié :
@task
def my_task(thing_num):
Thing.objects.get(num=thing_num)
with transaction.atomic():
Thing.objects.create(num=1)
my_task.enqueue(thing_num=1)
Pour éviter le scénario où my_task s’exécute avant que Thing ne soit commité dans la base de données, utilisez transaction.on_commit(), en liant tous les arguments à destination de enqueue() via functools.partial():
from functools import partial
from django.db import transaction
with transaction.atomic():
Thing.objects.create(num=1)
transaction.on_commit(partial(my_task.enqueue, thing_num=1))
Résultats des tâches¶
Lors de la mise en file d’attente d’une tâche Task, vous obtenez une instance TaskResult. Cette instance permet ensuite de récupérer le résultat de la tâche depuis un autre endroit (par exemple une autre requête ou une autre tâche).
Chaque TaskResult possède un id unique, qui peut être utilisé ensuite pour récupérer le résultat une fois que le code qui a mis la tâche en file d’attente ait terminé son travail.
La méthode get_result() peut récupérer un résultat sur la base de son id:
# Later, somewhere else...
result = email_users.get_result(result_id)
Pour obtenir un résultat TaskResult, quel que soit le type de tâche dont il provient, utilisez la méthode get_result() sur le moteur :
from django.tasks import default_task_backend
result = default_task_backend.get_result(result_id)
Pour récupérer des résultats dans un contexte asynchrone, aget_result() est disponible comme variante async de get_result(), aussi bien pour le moteur que pour une tâche Task.
Certains moteurs, tels que le moteur intégré ImmediateBackend, ne prennent pas en charge get_result(). Si on appelle cette méthode avec ces moteurs, une erreur NotImplementedError sera générée.
Mise à jour des résultats¶
Un objet TaskResult contient l’état d’exécution d’une tâche au moment où il est obtenu. Si la tâche se termine après que get_result() est appelée, l’objet ne se met pas à jour tout seul.
Pour actualiser les valeurs, appelez la méthode django.tasks.TaskResult.refresh():
>>> result.status
RUNNING
>>> result.refresh() # or await result.arefresh()
>>> result.status
SUCCESSFUL
Valeurs de renvoi¶
Si votre fonction de tâche renvoie quelque chose, on peut le récupérer à partir de l’attribut django.tasks.TaskResult.return_value:
>>> result.status
SUCCESSFUL
>>> result.return_value
42
Si la tâche n’a pas terminé son exécution, ou qu’elle a échoué, une exception ValueError est générée.
>>> result.status
RUNNING
>>> result.return_value
Traceback (most recent call last):
...
ValueError: Task has not finished yet
Erreurs¶
Si la tâche échoue et produit une exception, soit dans le contexte de la tâche ou lors de son exécution, l’exception et la trace d’erreur sont enregistrés dans la liste django.tasks.TaskResult.errors.
Chaque élément de errors est un objet TaskError contenant des informations au sujet de l’erreur produite durant l’exécution :
>>> result.errors[0].exception_class
<class 'ValueError'>
Notez qu’il ne s’agit que du type de l’exception et ne contient pas d’autres valeurs. Les informations de trace d’erreur sont réduites à une chaîne qui peut être utile en vue du débogage :
>>> result.errors[0].traceback
Traceback (most recent call last):
...
TypeError: Object of type datetime is not JSON serializable