- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
533 lines
13 KiB
Markdown
533 lines
13 KiB
Markdown
# Background Jobs & Queues mit BullMQ
|
||
|
||
**Meta-Description:** Background Job Processing mit BullMQ und Redis. Queues, Workers, Retries und Job Scheduling für Node.js.
|
||
|
||
**Keywords:** BullMQ, Background Jobs, Queue Processing, Redis Queue, Job Scheduling, Worker Threads, Async Processing
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
Nicht jede Aufgabe sollte im Request-Response-Zyklus laufen. **Background Jobs** ermöglichen Email-Versand, Image Processing, Reports – alles asynchron mit Retries, Prioritäten und Scheduling.
|
||
|
||
---
|
||
|
||
## Warum Background Jobs?
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ SYNCHRON vs ASYNCHRON │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Synchron (schlecht für): │
|
||
│ ├── Email-Versand (3-5s) │
|
||
│ ├── Image/Video Processing (10s+) │
|
||
│ ├── PDF Generation (2-5s) │
|
||
│ ├── Externe API Calls (variabel) │
|
||
│ └── Batch Operations (Minuten) │
|
||
│ │
|
||
│ User wartet... ⏳ │
|
||
│ │
|
||
│ Asynchron (Background): │
|
||
│ ├── Request → Response (50ms) │
|
||
│ ├── Job in Queue → Worker verarbeitet │
|
||
│ ├── Retries bei Fehlern │
|
||
│ ├── Rate Limiting │
|
||
│ └── Scheduling (Cron) │
|
||
│ │
|
||
│ User bekommt sofort Antwort! ✓ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## BullMQ Setup
|
||
|
||
```bash
|
||
npm install bullmq ioredis
|
||
```
|
||
|
||
```typescript
|
||
// lib/queue.ts
|
||
import { Queue, Worker, QueueEvents } from 'bullmq';
|
||
import IORedis from 'ioredis';
|
||
|
||
const connection = new IORedis(process.env.REDIS_URL!, {
|
||
maxRetriesPerRequest: null
|
||
});
|
||
|
||
// Queue definieren
|
||
export const emailQueue = new Queue('email', { connection });
|
||
export const imageQueue = new Queue('image-processing', { connection });
|
||
export const reportQueue = new Queue('reports', { connection });
|
||
|
||
// Queue Events (für Monitoring)
|
||
export const emailQueueEvents = new QueueEvents('email', { connection });
|
||
```
|
||
|
||
---
|
||
|
||
## Job Types definieren
|
||
|
||
```typescript
|
||
// types/jobs.ts
|
||
export interface EmailJob {
|
||
type: 'welcome' | 'reset-password' | 'notification';
|
||
to: string;
|
||
subject: string;
|
||
template: string;
|
||
data: Record<string, unknown>;
|
||
}
|
||
|
||
export interface ImageProcessingJob {
|
||
imageId: string;
|
||
operations: Array<{
|
||
type: 'resize' | 'crop' | 'watermark';
|
||
params: Record<string, unknown>;
|
||
}>;
|
||
outputFormat: 'webp' | 'avif' | 'jpeg';
|
||
}
|
||
|
||
export interface ReportJob {
|
||
reportType: 'daily' | 'weekly' | 'monthly';
|
||
userId: string;
|
||
dateRange: {
|
||
start: Date;
|
||
end: Date;
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Jobs hinzufügen
|
||
|
||
```typescript
|
||
// services/email.ts
|
||
import { emailQueue } from '@/lib/queue';
|
||
import { EmailJob } from '@/types/jobs';
|
||
|
||
export async function sendWelcomeEmail(userId: string, email: string) {
|
||
const job = await emailQueue.add('send-email', {
|
||
type: 'welcome',
|
||
to: email,
|
||
subject: 'Welcome to Our Platform!',
|
||
template: 'welcome',
|
||
data: { userId }
|
||
} satisfies EmailJob, {
|
||
// Job Options
|
||
attempts: 3,
|
||
backoff: {
|
||
type: 'exponential',
|
||
delay: 1000 // 1s, 2s, 4s
|
||
},
|
||
removeOnComplete: {
|
||
count: 1000 // Behalte letzte 1000 completed Jobs
|
||
},
|
||
removeOnFail: {
|
||
count: 5000 // Behalte letzte 5000 failed Jobs
|
||
}
|
||
});
|
||
|
||
return job.id;
|
||
}
|
||
|
||
// Mit Priorität
|
||
export async function sendUrgentEmail(data: EmailJob) {
|
||
await emailQueue.add('send-email', data, {
|
||
priority: 1 // Niedrigere Zahl = höhere Priorität
|
||
});
|
||
}
|
||
|
||
// Verzögerter Job
|
||
export async function scheduleEmail(data: EmailJob, delay: number) {
|
||
await emailQueue.add('send-email', data, {
|
||
delay // Millisekunden
|
||
});
|
||
}
|
||
|
||
// Geplanter Job (Cron)
|
||
export async function scheduleReports() {
|
||
await reportQueue.add('generate-report', {
|
||
reportType: 'daily',
|
||
// ...
|
||
}, {
|
||
repeat: {
|
||
pattern: '0 8 * * *' // Täglich um 8:00
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Worker implementieren
|
||
|
||
```typescript
|
||
// workers/email.worker.ts
|
||
import { Worker, Job } from 'bullmq';
|
||
import IORedis from 'ioredis';
|
||
import { Resend } from 'resend';
|
||
import { EmailJob } from '@/types/jobs';
|
||
|
||
const connection = new IORedis(process.env.REDIS_URL!, {
|
||
maxRetriesPerRequest: null
|
||
});
|
||
|
||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||
|
||
const emailWorker = new Worker<EmailJob>(
|
||
'email',
|
||
async (job: Job<EmailJob>) => {
|
||
console.log(`Processing job ${job.id}: ${job.data.type}`);
|
||
|
||
const { to, subject, template, data } = job.data;
|
||
|
||
// Template rendern
|
||
const html = await renderTemplate(template, data);
|
||
|
||
// Email senden
|
||
const result = await resend.emails.send({
|
||
from: 'noreply@example.com',
|
||
to,
|
||
subject,
|
||
html
|
||
});
|
||
|
||
console.log(`Email sent: ${result.id}`);
|
||
|
||
return { emailId: result.id };
|
||
},
|
||
{
|
||
connection,
|
||
concurrency: 5, // 5 Jobs parallel
|
||
limiter: {
|
||
max: 100, // Max 100 Jobs
|
||
duration: 60000 // Pro Minute (Rate Limiting)
|
||
}
|
||
}
|
||
);
|
||
|
||
// Event Handlers
|
||
emailWorker.on('completed', (job, result) => {
|
||
console.log(`Job ${job.id} completed with result:`, result);
|
||
});
|
||
|
||
emailWorker.on('failed', (job, error) => {
|
||
console.error(`Job ${job?.id} failed:`, error.message);
|
||
});
|
||
|
||
emailWorker.on('progress', (job, progress) => {
|
||
console.log(`Job ${job.id} progress: ${progress}%`);
|
||
});
|
||
|
||
export { emailWorker };
|
||
```
|
||
|
||
```typescript
|
||
// workers/image.worker.ts
|
||
import { Worker, Job } from 'bullmq';
|
||
import sharp from 'sharp';
|
||
import { ImageProcessingJob } from '@/types/jobs';
|
||
|
||
const imageWorker = new Worker<ImageProcessingJob>(
|
||
'image-processing',
|
||
async (job: Job<ImageProcessingJob>) => {
|
||
const { imageId, operations, outputFormat } = job.data;
|
||
|
||
// Bild laden
|
||
const image = await loadImage(imageId);
|
||
let pipeline = sharp(image);
|
||
|
||
// Operationen anwenden
|
||
for (let i = 0; i < operations.length; i++) {
|
||
const op = operations[i];
|
||
|
||
// Progress updaten
|
||
await job.updateProgress(Math.round((i / operations.length) * 100));
|
||
|
||
switch (op.type) {
|
||
case 'resize':
|
||
pipeline = pipeline.resize(op.params.width, op.params.height);
|
||
break;
|
||
case 'crop':
|
||
pipeline = pipeline.extract(op.params);
|
||
break;
|
||
case 'watermark':
|
||
pipeline = pipeline.composite([{
|
||
input: await loadWatermark(),
|
||
gravity: 'southeast'
|
||
}]);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Output
|
||
const output = await pipeline[outputFormat]().toBuffer();
|
||
|
||
// Speichern
|
||
const url = await uploadToStorage(output, `${imageId}.${outputFormat}`);
|
||
|
||
return { url, size: output.length };
|
||
},
|
||
{
|
||
connection,
|
||
concurrency: 2 // CPU-intensive, weniger parallel
|
||
}
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## Job Progress & Events
|
||
|
||
```typescript
|
||
// API Route: Job Status
|
||
// app/api/jobs/[id]/route.ts
|
||
import { emailQueue } from '@/lib/queue';
|
||
|
||
export async function GET(
|
||
request: Request,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
const job = await emailQueue.getJob(params.id);
|
||
|
||
if (!job) {
|
||
return Response.json({ error: 'Job not found' }, { status: 404 });
|
||
}
|
||
|
||
const state = await job.getState();
|
||
const progress = job.progress;
|
||
|
||
return Response.json({
|
||
id: job.id,
|
||
state,
|
||
progress,
|
||
data: job.data,
|
||
returnValue: job.returnvalue,
|
||
failedReason: job.failedReason,
|
||
timestamp: job.timestamp,
|
||
processedOn: job.processedOn,
|
||
finishedOn: job.finishedOn
|
||
});
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// Real-time Updates mit SSE
|
||
// app/api/jobs/[id]/stream/route.ts
|
||
import { emailQueueEvents } from '@/lib/queue';
|
||
|
||
export async function GET(
|
||
request: Request,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
const encoder = new TextEncoder();
|
||
|
||
const stream = new ReadableStream({
|
||
start(controller) {
|
||
const handleCompleted = ({ jobId, returnvalue }) => {
|
||
if (jobId === params.id) {
|
||
controller.enqueue(
|
||
encoder.encode(`data: ${JSON.stringify({
|
||
event: 'completed',
|
||
result: returnvalue
|
||
})}\n\n`)
|
||
);
|
||
controller.close();
|
||
}
|
||
};
|
||
|
||
const handleFailed = ({ jobId, failedReason }) => {
|
||
if (jobId === params.id) {
|
||
controller.enqueue(
|
||
encoder.encode(`data: ${JSON.stringify({
|
||
event: 'failed',
|
||
error: failedReason
|
||
})}\n\n`)
|
||
);
|
||
controller.close();
|
||
}
|
||
};
|
||
|
||
const handleProgress = ({ jobId, data }) => {
|
||
if (jobId === params.id) {
|
||
controller.enqueue(
|
||
encoder.encode(`data: ${JSON.stringify({
|
||
event: 'progress',
|
||
progress: data
|
||
})}\n\n`)
|
||
);
|
||
}
|
||
};
|
||
|
||
emailQueueEvents.on('completed', handleCompleted);
|
||
emailQueueEvents.on('failed', handleFailed);
|
||
emailQueueEvents.on('progress', handleProgress);
|
||
|
||
return () => {
|
||
emailQueueEvents.off('completed', handleCompleted);
|
||
emailQueueEvents.off('failed', handleFailed);
|
||
emailQueueEvents.off('progress', handleProgress);
|
||
};
|
||
}
|
||
});
|
||
|
||
return new Response(stream, {
|
||
headers: {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Connection': 'keep-alive'
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Job Scheduling (Cron)
|
||
|
||
```typescript
|
||
// Wiederkehrende Jobs einrichten
|
||
async function setupScheduledJobs() {
|
||
// Täglich um 8:00 UTC
|
||
await reportQueue.add('daily-report', {
|
||
reportType: 'daily'
|
||
}, {
|
||
repeat: {
|
||
pattern: '0 8 * * *'
|
||
},
|
||
jobId: 'daily-report' // Verhindert Duplikate
|
||
});
|
||
|
||
// Wöchentlich Montag 9:00
|
||
await reportQueue.add('weekly-report', {
|
||
reportType: 'weekly'
|
||
}, {
|
||
repeat: {
|
||
pattern: '0 9 * * 1'
|
||
},
|
||
jobId: 'weekly-report'
|
||
});
|
||
|
||
// Alle 5 Minuten
|
||
await emailQueue.add('process-email-queue', {}, {
|
||
repeat: {
|
||
every: 5 * 60 * 1000
|
||
}
|
||
});
|
||
}
|
||
|
||
// Scheduled Jobs verwalten
|
||
async function listScheduledJobs() {
|
||
const repeatableJobs = await emailQueue.getRepeatableJobs();
|
||
return repeatableJobs;
|
||
}
|
||
|
||
async function removeScheduledJob(key: string) {
|
||
await emailQueue.removeRepeatableByKey(key);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Dashboard & Monitoring
|
||
|
||
```typescript
|
||
// Bull Board Integration
|
||
import { createBullBoard } from '@bull-board/api';
|
||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||
import { ExpressAdapter } from '@bull-board/express';
|
||
|
||
const serverAdapter = new ExpressAdapter();
|
||
serverAdapter.setBasePath('/admin/queues');
|
||
|
||
createBullBoard({
|
||
queues: [
|
||
new BullMQAdapter(emailQueue),
|
||
new BullMQAdapter(imageQueue),
|
||
new BullMQAdapter(reportQueue)
|
||
],
|
||
serverAdapter
|
||
});
|
||
|
||
// In Express/Hono einbinden
|
||
app.use('/admin/queues', serverAdapter.getRouter());
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
```typescript
|
||
// 1. Idempotente Jobs (können mehrfach ausgeführt werden)
|
||
async function processOrder(job: Job<{ orderId: string }>) {
|
||
const order = await db.order.findUnique({
|
||
where: { id: job.data.orderId }
|
||
});
|
||
|
||
// Prüfen ob schon verarbeitet
|
||
if (order?.status === 'processed') {
|
||
return { skipped: true, reason: 'Already processed' };
|
||
}
|
||
|
||
// Verarbeiten
|
||
await db.order.update({
|
||
where: { id: order.id },
|
||
data: { status: 'processed' }
|
||
});
|
||
}
|
||
|
||
// 2. Graceful Shutdown
|
||
process.on('SIGTERM', async () => {
|
||
console.log('Shutting down workers...');
|
||
await emailWorker.close();
|
||
await imageWorker.close();
|
||
process.exit(0);
|
||
});
|
||
|
||
// 3. Dead Letter Queue
|
||
const dlQueue = new Queue('dead-letter', { connection });
|
||
|
||
emailWorker.on('failed', async (job, error) => {
|
||
if (job.attemptsMade >= job.opts.attempts!) {
|
||
await dlQueue.add('failed-email', {
|
||
originalJob: job.data,
|
||
error: error.message,
|
||
failedAt: new Date()
|
||
});
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
BullMQ bietet:
|
||
|
||
1. **Zuverlässigkeit**: Retries, Persistence, Acknowledgement
|
||
2. **Skalierbarkeit**: Mehrere Worker, Rate Limiting
|
||
3. **Flexibilität**: Scheduling, Prioritäten, Progress
|
||
4. **Monitoring**: Bull Board, Events, Metrics
|
||
|
||
Unverzichtbar für Production-Grade Async Processing.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Queue of tasks being processed by workers, assembly line concept"
|
||
2. "Background process running while user continues, async workflow"
|
||
3. "Redis connecting multiple workers, distributed job processing"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [BullMQ Documentation](https://docs.bullmq.io/)
|
||
- [BullMQ GitHub](https://github.com/taskforcesh/bullmq)
|
||
- [Bull Board](https://github.com/felixmosh/bull-board)
|
||
- [Redis Pub/Sub](https://redis.io/docs/latest/develop/interact/pubsub/)
|