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:
- Cross‑tenant leakage: A tenant with a large backlog would hog the single worker, delaying emails for other tenants.
- 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...
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 } });
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_IDwas 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 } }
);
}
Key points:
-
scheduleEmailJobsruns 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-workerthat receivesTENANT_IDat 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');
});
});
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
Top comments (0)