Oban : la file d'attente est une table, et c'est toute la différence
Il y a deux familles de systèmes de tâches en arrière-plan. Celles qui gardent la file dans un service à part — Redis, RabbitMQ — et celles qui la gardent dans la base de données qu’on a déjà. Oban est de la seconde, et ce choix a une conséquence qu’on ne mesure qu’en l’utilisant : chaque question qu’on se pose sur un job a une réponse dans une ligne SQL.
Trois expériences, sur Oban 2.24.1, Elixir 1.19.5, PostgreSQL 15. Chacune se termine par une lecture de la table.
Ce qu’il faut installer
Une dépendance, une migration, une entrée dans l’arbre de supervision, et un bloc de configuration :
# mix.exs
{:oban, "~> 2.24"}
# priv/repo/migrations/..._add_oban.exs
def up, do: Oban.Migration.up(version: 12)
def down, do: Oban.Migration.down(version: 1)
# lib/mon_app/application.ex, après le Repo
{Oban, Application.fetch_env!(:mon_app, Oban)}
# config/config.exs
config :mon_app, Oban,
repo: MonApp.Repo,
queues: [default: 10, courriels: 5],
plugins: [{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}]
queues: nomme les files et fixe leur concurrence — ici, cinq courriels à la fois, dix tâches par défaut. Le Pruner supprime les jobs terminés depuis plus de sept jours, sans quoi la table grossit indéfiniment. Et dans config/test.exs :
config :mon_app, Oban, testing: :manual
En test, aucun job ne s’exécute tout seul. On les insère, on vérifie qu’ils sont là, on les exécute à la main si on veut. Les tests restent déterministes.
Un worker
defmodule MonApp.Workers.EnvoiCourriel do
use Oban.Worker,
queue: :courriels,
max_attempts: 3,
unique: [period: 60, keys: [:destinataire]]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"destinataire" => dest, "echoue" => true}, attempt: n}) do
{:error, "SMTP indisponible pour #{dest} (tentative #{n})"}
end
def perform(%Oban.Job{args: %{"destinataire" => _dest}}) do
# l'envoi réel irait ici
:ok
end
end
Deux choses à voir avant les expériences. Les args arrivent avec des clés en chaînes, pas en atomes — ils ont fait l’aller-retour par une colonne JSON, et %{"destinataire" => ...} est la seule forme qui matche. Et attempt est fourni par Oban dans la structure du job : le worker sait à quelle tentative il en est.
Expérience 1 : un job qui réussit
{:ok, job} =
%{destinataire: "alice@example.com"}
|> MonApp.Workers.EnvoiCourriel.new()
|> Oban.insert()
%{id: 1, state: "available", queue: "courriels",
worker: "MonApp.Workers.EnvoiCourriel",
attempt: 0, max_attempts: 3, conflict?: false}
new/1 construit un changeset — comme Ecto, parce que c’en est un. Oban.insert/1 l’écrit dans oban_jobs avec l’état available : prêt, pas encore pris. Quelques secondes plus tard, une lecture de la table :
#1 completed tentative 1/3 erreurs=0
Expérience 2 : un job qui échoue
%{destinataire: "bob@example.com", echoue: true}
|> MonApp.Workers.EnvoiCourriel.new()
|> Oban.insert()
Le worker renvoie {:error, ...}. Voici la ligne quelques secondes après :
#2 retryable tentative 1/3 planifié 2026-09-08 19:28:48 erreurs=1
-> ** (Oban.PerformError) MonApp.Workers.EnvoiCourriel failed with {:error, "SMTP indisponible pour bob@example.com (tentative 1)"}
Tout y est. L’état est retryable, pas failed : il reste deux tentatives. La colonne scheduled_at porte la date de la prochaine — dix-huit secondes après l’échec, mesuré, avec un délai qui croît à chaque tentative. Et la colonne errors conserve chaque erreur, avec la tentative et le message, en JSON.
C’est la différence avec un service externe. Là, on chercherait dans des logs. Ici, la question « pourquoi le mail de Bob n’est pas parti ? » est un SELECT sur oban_jobs WHERE args->>'destinataire' = 'bob@example.com', et la réponse contient les trois messages d’erreur successifs. Après la troisième tentative, l’état passe à discarded, et la ligne reste — jusqu’à ce que le Pruner la retire, sept jours plus tard.
Expérience 3 : un doublon refusé
On réinsère exactement le premier job, même destinataire, dans la minute :
%{id: 1, state: "available", conflict?: true}
Le id renvoyé est 1 — celui du job déjà présent — et conflict? vaut true. Oban.insert/1 a répondu {:ok, job}, pas une erreur : du point de vue de l’appelant, le job existe, ce qui est vrai. C’est l’option unique: [period: 60, keys: [:destinataire]] du worker qui a joué — pendant soixante secondes, un seul job par destinataire, quels que soient les autres arguments.
Ce mécanisme règle un problème que tout le monde rencontre : un bouton cliqué deux fois, un webhook rejoué, un cron qui se chevauche. Sans unicité, deux mails partent. Avec, la seconde insertion est un constat, et c’est la base qui garantit l’unicité, pas un verrou en mémoire qui disparaît au redémarrage.
Pourquoi une table plutôt qu’un service
Ce que les trois expériences ont en commun, c’est que chaque réponse est venue d’une lecture SQL sur oban_jobs. On peut aller plus loin, et c’est là que la table devient un argument d’architecture :
- Un job inséré dans la même transaction qu’une écriture métier est atomique avec elle. Si la facture n’est pas créée, le mail de facture n’est pas planifié — sans coordination entre deux systèmes.
- Un redémarrage ne perd rien. Les jobs
availableetretryablesont repris là où ils étaient. - Il n’y a rien d’autre à déployer, surveiller ou sauvegarder. La base l’est déjà.
Le prix, c’est la charge sur PostgreSQL : chaque job est une ligne écrite, mise à jour, puis supprimée. Pour quelques milliers de jobs par minute, c’est indolore ; pour des centaines de milliers, il faut mesurer, et c’est le cas où un service dédié redevient un choix.
À retenir
- Oban stocke la file dans
oban_jobs, une table Ecto ordinaire. Chaque question sur un job est unSELECT. - Les
argsarrivent avec des clés en chaînes ;attemptest fourni par Oban. - Un job qui renvoie
{:error, _}passe enretryable, avec la date de la prochaine tentative dansscheduled_atet chaque erreur conservée danserrors. unique:refuse les doublons au niveau de la base ; l’insertion répond{:ok, job}avecconflict?: trueet l’identifiant du job existant.- En test,
testing: :manual: rien ne s’exécute tout seul. - Le
Prunerest nécessaire, sinon la table ne rétrécit jamais.