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 available et retryable sont 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 un SELECT.
  • Les args arrivent avec des clés en chaînes ; attempt est fourni par Oban.
  • Un job qui renvoie {:error, _} passe en retryable, avec la date de la prochaine tentative dans scheduled_at et chaque erreur conservée dans errors.
  • unique: refuse les doublons au niveau de la base ; l’insertion répond {:ok, job} avec conflict?: true et l’identifiant du job existant.
  • En test, testing: :manual : rien ne s’exécute tout seul.
  • Le Pruner est nécessaire, sinon la table ne rétrécit jamais.