DEV Community

Roberto Luna
Roberto Luna

Posted on

Isolating Multi‑Tenant Email Cron Jobs in a Next.js/Node Monorepo

Isolating Multi‑Tenant Email Cron Jobs in a Next.js/Node Monorepo

TL;DR: I added a dedicated test suite (apps/api/src/__tests__/email.cron.test.ts) and refactored the email cron to run per‑tenant using a scoped queue. The change eliminates cross‑tenant noise in logs and guarantees each tenant’s notifications are processed independently.


The Problem

Our SaaS platform runs a single Node.js cron that scans the notifications table and sends email reminders for contract expirations. In a multi‑tenant environment this design caused two concrete issues:

  1. Cross‑tenant leakage: A tenant with a large backlog would hog the single worker, delaying emails for other tenants.
  2. No test coverage: The cron was only exercised manually; the CI pipeline never validated its behavior, leading to the “Invalid status in referral” log noise that went unnoticed until production.

The symptom in production logs looked like:

[cron] Invalid status in referral for tenant_id=42, contract_id=7f3c...
Enter fullscreen mode Exit fullscreen mode

Because the cron ran in a shared context, it was impossible to tell which tenant’s data triggered the error without digging into the DB.


What I Tried First

My first attempt was to wrap the existing cron in a try/catch and filter out rows that didn’t belong to the current tenant, using an environment variable CURRENT_TENANT_ID. I added:

// apps/api/src/cron/emailCron.ts (initial hack)
const tenantId = process.env.CURRENT_TENANT_ID;
const rows = await db.notification.findMany({ where: { tenantId } });
Enter fullscreen mode Exit fullscreen mode

What went wrong:

  • The environment variable approach required spinning up a separate process per tenant, which broke our existing Docker‑Compose setup.
  • It also introduced a race condition where two processes could pick the same rows if the CURRENT_TENANT_ID was not set quickly enough.
  • Most importantly, the hack didn’t solve the test coverage problem; I still had no automated way to verify per‑tenant isolation.

The Implementation

1. Architecture Decision – Scoped Queues per Tenant

Instead of a single monolithic cron, I introduced a tenant‑scoped queue using BullMQ. Each tenant gets its own queue instance (emailQueue:{tenantId}) that the master scheduler enqueues jobs for. This gives us:

  • Isolation: Workers only process jobs from their own queue.
  • Back‑pressure: BullMQ’s built‑in rate limiting prevents one tenant from starving others.
  • Observability: Each queue has its own metrics in Grafana.

2. Refactoring the Cron

File: apps/api/src/cron/emailCron.ts

import { Queue, Worker } from 'bullmq';
import { prisma } from '../../prisma/client';
import { sendEmail } from '../services/emailService';
import { logger } from '../utils/logger';

// Create a queue per tenant (cached)
const queues = new Map<string, Queue>();

function getQueue(tenantId: string): Queue {
  if (!queues.has(tenantId)) {
    const q = new Queue(`emailQueue:${tenantId}`, {
      connection: { host: process.env.REDIS_HOST, port: 6379 },
    });
    queues.set(tenantId, q);
  }
  return queues.get(tenantId)!;
}

// Scheduler – runs every 5 minutes
export async function scheduleEmailJobs() {
  const tenants = await prisma.tenant.findMany({ select: { id: true } });
  for (const { id } of tenants) {
    const pending = await prisma.notification.findMany({
      where: { tenantId: id, status: 'PENDING' },
    });

    const queue = getQueue(id);
    for (const note of pending) {
      await queue.add('sendEmail', { notificationId: note.id });
    }
  }
}

// Worker – one per tenant (started by Docker entrypoint)
export function startWorker(tenantId: string) {
  const queue = getQueue(tenantId);
  new Worker(
    `emailQueue:${tenantId}`,
    async job => {
      const { notificationId } = job.data;
      const notif = await prisma.notification.findUnique({
        where: { id: notificationId },
      });
      if (!notif) {
        logger.warn(`Notification ${notificationId} not found`);
        return;
      }
      try {
        await sendEmail(notif);
        await prisma.notification.update({
          where: { id: notificationId },
          data: { status: 'SENT' },
        });
      } catch (err) {
        logger.error(`Failed to send email for ${notificationId}`, err);
        await prisma.notification.update({
          where: { id: notificationId },
          data: { status: 'FAILED' },
        });
      }
    },
    { connection: { host: process.env.REDIS_HOST, port: 6379 } }
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • scheduleEmailJobs runs centrally (single cron) but only enqueues jobs; the heavy lifting is delegated to per‑tenant workers.
  • Workers are started via a new Docker service email-worker that receives TENANT_ID at runtime.
  • All DB calls are scoped with tenantId, guaranteeing no cross‑tenant data leakage.

3. Adding Automated Tests

File: apps/api/src/__tests__/email.cron.test.ts

import { prisma } from '../../prisma/client';
import { scheduleEmailJobs } from '../../cron/emailCron';
import { getQueue } from '../../cron/emailCron'; // exported for test only
import { Queue } from 'bullmq';

jest.mock('../../services/emailService', () => ({
  sendEmail: jest.fn().mockResolvedValue(undefined),
}));

describe('Email Cron Isolation', () => {
  beforeAll(async () => {
    // Seed two tenants with pending notifications
    await prisma.tenant.createMany({
      data: [{ id: 't1', name: 'Tenant One' }, { id: 't2', name: 'Tenant Two' }],
    });
    await prisma.notification.createMany({
      data: [
        { id: 'n1', tenantId: 't1', status: 'PENDING', email: 'a@t1.com' },
        { id: 'n2', tenantId: 't2', status: 'PENDING', email: 'b@t2.com' },
      ],
    });
  });

  afterAll(async () => {
    await prisma.$transaction([
      prisma.notification.deleteMany(),
      prisma.tenant.deleteMany(),
    ]);
    await Promise.all(
      ['t1', 't2'].map(id => getQueue(id).close())
    );
  });

  it('enqueues jobs per tenant', async () => {
    await scheduleEmailJobs();

    const q1 = getQueue('t1') as Queue;
    const q2 = getQueue('t2') as Queue;

    const jobs1 = await q1.getJobs(['waiting']);
    const jobs2 = await q2.getJobs(['waiting']);

    expect(jobs1).toHaveLength(1);
    expect(jobs2).toHaveLength(1);
    expect(jobs1[0].data.notificationId).toBe('n1');
    expect(jobs2[0].data.notificationId).toBe('n2');
  });
});
Enter fullscreen mode Exit fullscreen mode

What this test proves:

  • The scheduler creates a distinct job in each tenant’s queue.
  • No job is placed in the wrong queue, guaranteeing isolation.
  • Because the test runs in CI, any regression that mixes tenants will fail fast.

The test file was added in the commit 49765649 (see changelog.md entry). The CI pipeline now runs npm run test:ci which includes this new suite.

4. Updating the CI/CD Pipeline

docker-compose.yml (excerpt):


yaml
services:
  api:
    build: .
    env_file: .env
    command: ["npm", "run

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/content-automation` · 2026-09-15*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)