My App

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 :

  1. Périodiques : sync Mews quotidienne, cleanup des sessions expirées, digest email hebdo, réconciliation Stripe
  2. Déclenchées par une action :
    • Après confirmGuestArrivalbridgeCheckIn vers le PMS (peut échouer si Mews down)
    • Après un upsell payé → bridgePostCharges vers le PMS
    • Après un sendCheckInEmail → validation Reacher + envoi SMTP
    • Webhook Stripe reçu → verify signature + update DB + notifications

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

QueueRetriesBackoffDLQ après
mews-sync3exp 30s3 échecs
pms-bridge5exp 5s5 échecs
email3exp 60s3 échecs
stripe-webhook5exp 10s5 é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 %)

On this page