Skip to main content

Command Palette

Search for a command to run...

Task Scheduling: Distributed Cron Alternative

Learn: Task Scheduling: Distributed Cron Alternative

Updated
7 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Task Scheduling: Distributed Cron Alternative with Agenda & node-schedule

Problem

Traditional cron jobs have significant limitations in modern distributed systems:

  • Single-machine dependency: Cron runs only on one server; if it crashes, scheduled tasks fail
  • No persistence: Job state is lost on restart
  • Poor scalability: Difficult to manage across multiple instances
  • Limited monitoring: No built-in visibility into job execution
  • No retry logic: Failed tasks aren't automatically retried
  • Timezone complexity: Managing timezones across systems is cumbersome
  • No job queuing: Tasks execute immediately or not at all

Solution

Two complementary Node.js libraries solve these problems:

  1. node-schedule: Simple, in-process scheduling for single-server scenarios
  2. Agenda: Distributed job queue with MongoDB persistence for production systems

Both provide:

  • Flexible scheduling syntax
  • Job persistence and recovery
  • Retry mechanisms
  • Timezone support
  • Event-driven architecture
  • Graceful shutdown handling

Code Implementation

1. node-schedule: Simple Local Scheduling

// simple-scheduler.js
const schedule = require('node-schedule');

// Basic cron-like syntax
const job1 = schedule.scheduleJob('0 9 * * 1-5', () => {
  console.log('Weekday 9 AM task executed');
  sendDailyReport();
});

// Recurrence rule for complex patterns
const rule = new schedule.RecurrenceRule();
rule.minute = [0, 15, 30, 45];
rule.hour = 9;
rule.dayOfWeek = [0, 6]; // Weekend

const job2 = schedule.scheduleJob(rule, () => {
  console.log('Weekend 9 AM every 15 minutes');
  checkSystemHealth();
});

// Date-based scheduling
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(14, 30, 0, 0);

const job3 = schedule.scheduleJob(tomorrow, () => {
  console.log('One-time task tomorrow at 2:30 PM');
  generateMonthlyReport();
});

// Interval-based scheduling
const job4 = schedule.scheduleJob('*/5 * * * *', () => {
  console.log('Every 5 minutes');
  syncData();
});

// Cancel a job
job1.cancel();

// Graceful shutdown
process.on('SIGTERM', () => {
  schedule.gracefulShutdown();
  process.exit(0);
});

2. Agenda: Production-Grade Distributed Scheduling

// agenda-setup.js
const Agenda = require('agenda');
const mongoConnectionString = 'mongodb://localhost:27017/jobs';

const agenda = new Agenda({ db: { address: mongoConnectionString } });

// Define job types
agenda.define('send-email', async (job) => {
  const { email, subject } = job.attrs.data;
  console.log(`Sending email to ${email}: ${subject}`);

  try {
    await sendEmail(email, subject);
    console.log('Email sent successfully');
  } catch (error) {
    console.error('Email failed:', error);
    throw error; // Agenda will retry
  }
});

agenda.define('cleanup-old-files', async (job) => {
  console.log('Starting cleanup job');
  const deletedCount = await deleteFilesOlderThan(30);
  job.attrs.data.deletedCount = deletedCount;
  console.log(`Deleted ${deletedCount} files`);
});

agenda.define('generate-report', async (job) => {
  const { reportType, userId } = job.attrs.data;
  console.log(`Generating ${reportType} report for user ${userId}`);

  const report = await generateReport(reportType, userId);
  await saveReport(report);
});

// Configure retry logic
agenda.define('process-payment', 
  {
    concurrency: 5,
    lockLimit: 0,
    lockInterval: 5000,
  },
  async (job) => {
    const { paymentId } = job.attrs.data;
    console.log(`Processing payment ${paymentId}`);

    try {
      await processPayment(paymentId);
    } catch (error) {
      // Automatic retry with exponential backoff
      throw error;
    }
  }
);

// Start agenda
(async () => {
  await agenda.start();
  console.log('Agenda started');
})();

module.exports = agenda;

3. Scheduling Jobs with Agenda

// job-scheduler.js
const agenda = require('./agenda-setup');

// Schedule recurring jobs
async function scheduleRecurringJobs() {
  // Every day at 2 AM
  agenda.every('0 2 * * *', 'cleanup-old-files', {
    retries: 3,
    priority: 'high',
  });

  // Every Monday at 9 AM
  agenda.every('0 9 * * 1', 'generate-report', {
    reportType: 'weekly',
    userId: 'admin',
  });

  // Every 30 minutes
  agenda.every('*/30 * * * *', 'send-email', {
    email: 'alerts@company.com',
    subject: 'System Status Update',
  });

  // One-time job in 5 minutes
  const job = agenda.schedule(new Date(Date.now() + 5 * 60000), 'process-payment', {
    paymentId: 'PAY-12345',
  });

  console.log('Jobs scheduled');
}

// Handle job events
agenda.on('success', (job) => {
  console.log(`✓ Job succeeded: ${job.attrs.name}`);
});

agenda.on('fail', (error, job) => {
  console.error(`✗ Job failed: ${job.attrs.name}`, error.message);
});

agenda.on('start', (job) => {
  console.log(`→ Job started: ${job.attrs.name}`);
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('Shutting down gracefully...');
  await agenda.stop();
  process.exit(0);
});

scheduleRecurringJobs().catch(console.error);

4. Advanced: Multi-Instance Coordination

// distributed-scheduler.js
const Agenda = require('agenda');
const os = require('os');

const agenda = new Agenda({
  db: { address: 'mongodb://localhost:27017/jobs' },
  processEvery: '30 seconds',
  maxConcurrency: 20,
  defaultConcurrency: 5,
  lockLimit: 0,
});

const instanceId = `${os.hostname()}-${process.pid}`;

agenda.define('heavy-computation', 
  { concurrency: 1, lockLimit: 1 },
  async (job) => {
    console.log(`[${instanceId}] Running heavy computation`);

    // Only one instance runs this at a time
    const result = await performHeavyComputation();
    job.attrs.data.result = result;
  }
);

agenda.define('distributed-task',
  { concurrency: 3 },
  async (job) => {
    console.log(`[${instanceId}] Processing distributed task`);

    // Multiple instances can run this concurrently
    const { taskId } = job.attrs.data;
    await processTask(taskId);
  }
);

// Health check job
agenda.define('health-check', async (job) => {
  const health = {
    instance: instanceId,
    timestamp: new Date(),
    uptime: process.uptime(),
    memory: process.memoryUsage(),
  };

  console.log('Health check:', health);
  await saveHealthMetrics(health);
});

// Schedule health check every minute
agenda.every('* * * * *', 'health-check');

// Start with error handling
(async () => {
  try {
    await agenda.start();
    console.log(`Agenda started on ${instanceId}`);

    // Schedule jobs
    agenda.every('0 3 * * *', 'heavy-computation');
    agenda.every('*/10 * * * *', 'distributed-task', { taskId: 'batch-1' });

  } catch (error) {
    console.error('Failed to start agenda:', error);
    process.exit(1);
  }
})();

module.exports = agenda;

5. Express Integration with Job Management UI

// server.js
const express = require('express');
const agenda = require('./distributed-scheduler');

const app = express();
app.use(express.json());

// Get all jobs
app.get('/api/jobs', async (req, res) => {
  try {
    const jobs = await agenda.jobs({});
    res.json(jobs.map(job => ({
      id: job._id,
      name: job.attrs.name,
      nextRunAt: job.attrs.nextRunAt,
      lastRunAt: job.attrs.lastRunAt,
      failCount: job.attrs.failCount,
      data: job.attrs.data,
    })));
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Create new job
app.post('/api/jobs', async (req, res) => {
  try {
    const { name, schedule, data } = req.body;

    if (!name || !schedule) {
      return res.status(400).json({ error: 'Missing name or schedule' });
    }

    const job = agenda.schedule(schedule, name, data);
    res.status(201).json({ 
      id: job._id,
      message: 'Job scheduled successfully' 
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Cancel job
app.delete('/api/jobs/:id', async (req, res) => {
  try {
    await agenda.cancel({ _id: req.params.id });
    res.json({ message: 'Job cancelled' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Get job statistics
app.get('/api/stats', async (req, res) => {
  try {
    const jobs = await agenda.jobs({});
    const stats = {
      total: jobs.length,
      pending: jobs.filter(j => !j.attrs.lastRunAt).length,
      failed: jobs.filter(j => j.attrs.failCount > 0).length,
      nextRun: jobs
        .filter(j => j.attrs.nextRunAt)
        .sort((a, b) => a.attrs.nextRunAt - b.attrs.nextRunAt)[0],
    };
    res.json(stats);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Tips & Best Practices

1. Choose the Right Tool

Featurenode-scheduleAgenda
Single server
Distributed
Persistence
ScalabilityLowHigh
ComplexityLowMedium
Best forDev/testingProduction

2. Error Handling & Retries

agenda.define('resilient-job', async (job) => {
  const maxRetries = 3;
  const retryDelay = 5000;

  try {
    await riskyOperation();
  } catch (error) {
    if (job.attrs.failCount < maxRetries) {
      // Reschedule for retry
      job.schedule(new Date(Date.now() + retryDelay));
      await job.save();
    } else {
      // Log to error tracking service
      await logToSentry(error, job);
      throw error;
    }
  }
});

3. Timezone Handling

const TZ = 'America/New_York';

// node-schedule with timezone
const rule = new schedule.RecurrenceRule();
rule.hour = 9;
rule.minute = 0;
rule.tz = TZ;

schedule.scheduleJob(rule, () => {
  console.log('9 AM in New York');
});

// Agenda with timezone
agenda.every('0 9 * * *', 'daily-task', {}, { tz: TZ });

4. Monitoring & Logging

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'jobs.log' }),
  ],
});

agenda.on('success', (job) => {
  logger.info('Job success', {
    name: job.attrs.name,
    duration: Date.now() - job.attrs.lastRunAt,
  });
});

agenda.on('fail', (error, job) => {
  logger.error('Job failed', {
    name: job.attrs.name,
    error: error.message,
    failCount: job.attrs.failCount,
  });
});

5. Testing Scheduled Jobs

// test-scheduler.js
const { expect } = require('chai');
const sinon = require('sinon');
const agenda = require('./agenda-setup');

describe('Scheduled Jobs', () => {
  it('should execute cleanup job', async () => {
    const stub = sinon.stub().resolves();

    agenda.define('test-cleanup', stub);

    const job = agenda.create('test-cleanup').save();
    await job.run();

    expect(stub.calledOnce).to.be.true;
  });

  it('should retry failed jobs', async () => {
    const stub = sinon.stub()
      .onFirstCall().rejects(new Error('Fail'))
      .onSecondCall().resolves();

    agenda.define('test-retry', stub);

    const job = agenda.create('test-retry').save();

    try {
      await job.run();
    } catch (e) {
      // Expected
    }

    await job.run();
    expect(stub.calledTwice).to.be.true;
  });
});

6. Performance Optimization

// Tune for your workload
const agenda = new Agenda({
  db: { address: mongoConnectionString },
  processEvery: '10 seconds',      // Check for jobs every 10s
  maxConcurrency: 50,              // Max concurrent jobs
  defaultConcurrency: 5,           // Default per job type
  lockLimit: 0,                    // Unlimited locks
  lockInterval: 5000,              // Lock check interval
  defaultLockLimit: 0,
});

// Batch similar jobs
agenda.every('*/5 * * * *', 'batch-process', {
  batchSize: 100,
  timeout: 30000,
});

7. Production Checklist

  • ✓ Use MongoDB with replication for high availability
  • ✓ Implement comprehensive error logging
  • ✓ Set up monitoring dashboards
  • ✓ Configure graceful shutdown handlers
  • ✓ Use environment-specific configurations
  • ✓ Implement job timeouts
  • ✓ Test failure scenarios
  • ✓ Document job dependencies
  • ✓ Set up alerts for failed jobs
  • ✓ Regular backup of job database

Conclusion

node-schedule excels for simple, single-server scenarios with minimal overhead. Agenda is the production choice for distributed systems requiring reliability, persistence, and scalability. Combine them strategically: use node-schedule for lightweight tasks and Agenda for critical, distributed workloads.