ADR-04 — Jobs async hybrides cron + BullMQ
@elysiajs/cron pour le périodique, BullMQ + Redis pour le déclenché avec retries
Statut : Accepté Date : 2026-04 Sujet : Exécution des tâches asynchrones (sync PMS, emails, webhooks, bridges)
Contexte
Bell déclenche beaucoup d'opérations async :
- Périodiques : sync Mews quotidienne, cleanup des sessions expirées, digest email hebdo, réconciliation Stripe
- Déclenchées par une action :
- Après
confirmGuestArrival→bridgeCheckInvers le PMS (peut échouer si Mews down) - Après un upsell payé →
bridgePostChargesvers le PMS - Après un
sendCheckInEmail→ validation Reacher + envoi SMTP - Webhook Stripe reçu → verify signature + update DB + notifications
- Après
Dans l'ancien projet, on faisait fire-and-forget avec .catch(() => {}). Résultat : quand Mews était HS 3 minutes, les bridges échouaient silencieusement, les statuts DB dérivaient, et personne ne le voyait jusqu'à la prochaine facture contestée.
Alternatives considérées
Option 1 — @elysiajs/cron uniquement
Plugin officiel Elysia pour du cron in-process.
Pour :
- Natif Elysia, zero infra additionnelle
- Syntaxe propre, typé
- Redémarre avec le process
Contre :
- In-process — si le serveur Elysia redémarre pendant un job, le job est perdu (ou dupliqué si pas d'idempotency)
- Pas de retries structurés — il faut coder soi-même le retry exponentiel
- Pas de queue — les jobs déclenchés à la demande sont exécutés in-request (blocking) ou en background sans visibilité
- Pas de DLQ (dead-letter queue) — impossible de voir les jobs qui ont échoué
Suffisant pour le périodique simple, insuffisant pour les bridges critiques.
Option 2 — BullMQ + Redis uniquement
Queue système classique Node/Bun.
Pour :
- Persistant — les jobs survivent aux redémarrages
- Retries exponentiels intégrés
- DLQ pour les échecs définitifs
- Bull Board dashboard pour visualiser les queues
- Scale horizontal : ajouter un worker = plus de throughput
Contre :
- Infrastructure Redis obligatoire (on l'a déjà)
- Worker process séparé à faire tourner (
apps/worker) - Pour du cron simple, c'est trop de cérémonie
Option 3 — Service managé (Trigger.dev, Inngest)
SaaS pour jobs async.
Contre :
- Coût mensuel
- Dépendance externe pour un projet qu'on veut maîtriser
- Pas nécessaire à notre échelle
Option 4 — Hybride @elysiajs/cron + BullMQ (retenu)
Utiliser le bon outil pour chaque usage.
Décision
Hybride, répartition claire par cas d'usage.
@elysiajs/cron pour le périodique simple
Tourne dans le process Elysia principal (apps/server) :
// apps/server/src/cron.ts
import { cron } from "@elysiajs/cron";
export const cronJobs = new Elysia()
.use(cron({
name: "cleanup-expired-sessions",
pattern: "0 3 * * *", // 3h du matin
run: async () => {
await db.delete(session).where(lt(session.expiresAt, new Date()));
},
}))
.use(cron({
name: "mews-daily-sync",
pattern: "0 4 * * *", // 4h du matin
run: async () => {
const integrations = await db.select().from(integration)
.where(and(eq(integration.isActive, true), eq(integration.provider, "mews")));
for (const int of integrations) {
await queues.mewsSync.add("full-sync", { integrationId: int.id });
}
},
}));Notez : le cron ne fait pas le travail lui-même, il enqueue des jobs BullMQ pour que la sync lourde tourne dans un worker séparé.
BullMQ pour tout ce qui nécessite retries, persistence, ou isolation
Worker séparé dans apps/worker :
// apps/worker/src/index.ts
import { Worker } from "bullmq";
new Worker("mews-sync", async (job) => {
const { integrationId } = job.data;
const adapter = await createAdapter(integrationId);
return await syncService.fullSync(adapter);
}, {
connection: redis,
concurrency: 3,
});
new Worker("pms-bridge", async (job) => {
const { type, orgId, guestId, items } = job.data;
switch (type) {
case "check-in": return bridgeCheckIn(orgId, guestId);
case "check-out": return bridgeCheckOut(orgId, guestId);
case "post-charges": return bridgePostCharges(orgId, guestId, items);
case "update-room-status": return bridgeUpdateRoomStatus(orgId, items);
}
}, {
connection: redis,
concurrency: 10,
});
new Worker("email", async (job) => {
const { to, template, data } = job.data;
if (process.env.REACHER_ENABLED) {
const check = await reacher.verify(to);
if (check.reachable === "invalid") return { skipped: true, reason: "invalid" };
}
return sendMail(to, template, data);
}, {
connection: redis,
concurrency: 20,
});Producer côté API
Dans les routes Elysia, on enqueue plutôt qu'on exécute :
// packages/api/src/modules/cardex/cardex.service.ts
export async function confirmGuestArrival(orgId: string, guestId: string) {
await db.update(guest).set({ checkInStatus: "arrived" }).where(...);
await db.update(room).set({ status: "occupied" }).where(...);
// Enqueue le bridge PMS — ne bloque pas la response API
await queues.pmsBridge.add("check-in", { type: "check-in", orgId, guestId }, {
attempts: 5,
backoff: { type: "exponential", delay: 5000 },
});
await redis.publish(`guest:${guestId}:status`, JSON.stringify({ status: "arrived" }));
}Configuration BullMQ standard
| Queue | Retries | Backoff | DLQ après |
|---|---|---|---|
mews-sync | 3 | exp 30s | 3 échecs |
pms-bridge | 5 | exp 5s | 5 échecs |
email | 3 | exp 60s | 3 échecs |
stripe-webhook | 5 | exp 10s | 5 échecs |
Organisation monorepo
apps/
├── server → Elysia HTTP + @elysiajs/cron + producer BullMQ
└── worker → Process Bun séparé, consomme les queues BullMQ
packages/
└── api
└── src/
├── modules/ → services métier, enqueue jobs
└── jobs/ → définitions des jobs (types, processors)Le worker importe les processors depuis packages/api/src/jobs/. Même code côté producer et consumer — pas de drift.
Conséquences
Positives :
- Zéro silent failure — chaque job a un statut visible (Bull Board)
- Retries exponentiels gratuits pour les bridges PMS (robustesse contre Mews flaky)
- Worker scale-out indépendant du serveur HTTP
- Cron simple reste simple, pas de cérémonie BullMQ pour un cleanup session
Négatives :
- Deux process à faire tourner en prod (
server+worker) — trivial avec Docker Compose - Bull Board à sécuriser (admin only)
- Redis devient critique — si Redis tombe, plus de queue. Mitigation : Redis persistence + alerting
Métriques à surveiller
- Taille des queues BullMQ (cible : < 100 jobs waiting en régime stable)
- Taux d'échec par queue (cible : < 1 %, spike signale un problème upstream — Mews down, SMTP HS)
- Latence médiane des bridges PMS (cible : < 3s p95 pour check-in Mews)
- Nombre de jobs en DLQ (cible : 0, chaque job en DLQ = investigation)
- Uptime du worker process (cible : > 99.9 %)