A collection of utilities for BullMQ that provides powerful distributed batch processing and rate limiting capabilities.
Bull Utils provides:
- DistributedBatchProcessor: Process large datasets in smaller, distributed batches over time, preventing system overload by cycling through numbered slots.
- RateLimiter: A Redis-backed distributed rate limiter using a fixed window algorithm, with support for grouped rate limiting by tenant, API key, or any custom key.
- Distributed Processing: Automatically distributes work across time slots to prevent system overload
- State Persistence: Uses Redis to maintain slot state across job restarts
- Flexible Scheduling: Supports hourly, daily, and weekly cycle times
- Lifecycle Hooks: Built-in hooks for monitoring and error handling
- TypeScript Support: Full TypeScript definitions and type safety
- Stop Conditions: Custom logic to terminate job schedulers when needed
- Grouped Rate Limiting: Redis-backed rate limiting with support for independent rate limits per group key (e.g., per tenant)
- BullMQ Integration: Rate-limited processor wrapper that automatically delays jobs when rate limited
pnpm install bull-utilsimport { DistributedBatchProcessor } from "bull-utils";
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
// Create Redis connection
const redis = new Redis();
// Create a queue
const queue = new Queue("user-notifications", { connection: redis });
// Create the processor
const processor = new DistributedBatchProcessor({
queue,
id: "user-notifications",
cycleTime: "day", // Process all users once per day
everyMs: 5 * 60 * 1000, // Run every 5 minutes
});
// Build the processor function
const processorFn = await processor.build({
dataCallback: async (slotContext, job) => {
// Fetch users for this specific slot
// Use modulo to distribute users across slots
const users = await fetchUsersForSlot(
slotContext.currentSlot,
slotContext.totalSlots,
);
return users;
},
processCallback: async (user, slotContext, job) => {
// Process each user
await sendNotificationEmail(user.email);
console.log(`Processed user ${user.id} in slot ${slotContext.currentSlot}`);
},
hooks: {
onStartBatch: async (job) => {
console.log("Starting batch processing...");
},
onCompleteBatch: async (job) => {
console.log("Batch completed successfully");
},
onError: async (err, job) => {
console.error("Batch processing failed:", err);
},
},
});
// Use with BullMQ worker
const worker = new Worker("user-notifications", processorFn, {
connection: redis,
});const processor = new DistributedBatchProcessor({
queue,
id: "data-migration",
cycleTime: "week",
runOnce: true, // Run through all slots once and stop
});
const processorFn = await processor.build({
dataCallback: async (slotContext) => {
return fetchDataForSlot(slotContext.currentSlot, slotContext.totalSlots);
},
processCallback: async (record, slotContext) => {
await processRecord(record);
},
stopCondition: (slotContext, job) => {
// Stop if we've processed enough records
return slotContext.processedCount >= 1000;
},
hooks: {
onComplete: async (job) => {
console.log("Migration completed!");
},
},
});The processor uses a simple but effective distribution strategy:
// In your dataCallback, partition data by slot
dataCallback: async (slotContext) => {
const allRecords = await fetchAllRecords();
// Distribute records across slots using modulo
const recordsForThisSlot = allRecords.filter(
(record, index) =>
index % slotContext.totalSlots === slotContext.currentSlot,
);
return recordsForThisSlot;
};- Initialization: The processor calculates total slots based on cycle time and execution frequency
- Slot Management: Each job run processes data for the current slot, then advances to the next slot
- State Persistence: Current slot is stored in Redis and survives application restarts
- Data Distribution: Your
dataCallbackuses the slot information to partition data - Cycling: After processing all slots, the cycle starts over from slot 0
- Goal: Send notifications to all users once per day
- Setup: 288 slots (5-minute intervals over 24 hours)
- Result: Each user gets processed once per day, spread across time
- Goal: Migrate large dataset without overwhelming the system
- Setup: 1008 slots (5-minute intervals over a week)
- Result: Data is migrated gradually over a week
- Goal: Generate reports for all customers weekly
- Setup: 2016 slots (5-minute intervals over a week)
- Result: Each customer gets a report once per week
Bull Utils includes a distributed rate limiter that uses Redis for shared state, allowing you to enforce rate limits across multiple processes and servers.
The RateLimiter class implements a fixed window rate limiting algorithm. It uses Lua scripts for atomic operations, ensuring no race conditions when multiple processes attempt to acquire tokens simultaneously.
import { RateLimiter } from "bull-utils";
import Redis from "ioredis";
const redis = new Redis();
const rateLimiter = new RateLimiter({
redis,
limit: 100, // 100 requests
windowMs: 60000, // per minute
keyPrefix: "myapp",
});
// Acquire a token (non-blocking)
const result = await rateLimiter.acquire("tenant-123");
if (result.acquired) {
console.log(`Token acquired! ${result.remainingTokens} tokens remaining`);
// Proceed with rate-limited operation
} else {
console.log(`Rate limited. Retry in ${result.ttlSeconds} seconds`);
// Handle rate limit - move to delayed queue or reject
}
// Check status without consuming a token
const status = await rateLimiter.check("tenant-123");
if (status.isLimited) {
console.log(`Rate limited, resets in ${status.resetInMs}ms`);
} else {
console.log(`${status.remainingTokens} tokens available`);
}
// Reset a specific group's rate limit
await rateLimiter.reset("tenant-123");The createRateLimitedProcessor function wraps a BullMQ processor with rate limiting. When the rate limit is exceeded, the job is automatically moved to a delayed state and will be retried when the rate limit window resets.
import { createRateLimitedProcessor } from "bull-utils";
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
const redis = new Redis();
// Create a rate-limited processor
const rateLimitedProcessor = createRateLimitedProcessor(
{
redis,
limit: 10, // 10 API calls
windowMs: 1000, // per second
// Extract the group key from the job - rate limits are applied per group
groupKeyFn: (job) => job.data.tenantId ?? "default",
},
// Your actual processing logic
async (job) => {
await callExternalAPI(job.data);
return { success: true };
},
);
// Use with a BullMQ worker
const worker = new Worker("api-calls", rateLimitedProcessor, {
connection: redis,
});- Fixed Window Algorithm: Counts requests within discrete time windows
- Grouped Rate Limits: Each group key (e.g., tenant ID) has independent rate limits
- Atomic Operations: Uses Lua scripts to prevent race conditions under high concurrency
- BullMQ Integration: Automatically delays and retries jobs when rate limited
- Status Checking: Check rate limit status without consuming tokens
- Manual Reset: Reset rate limits for specific groups when needed