Skip to content

Latest commit

 

History

History
555 lines (443 loc) · 14.9 KB

File metadata and controls

555 lines (443 loc) · 14.9 KB
title Scheduling Pattern 3: Schedule Tasks with Cron Expressions
id scheduling-pattern-cron-expressions
skillLevel intermediate
applicationPatternId scheduling-periodic-tasks
summary Use cron expressions to schedule tasks at specific times and intervals, enabling calendar-based scheduling with timezone support.
tags
scheduling
cron
periodic-tasks
time-based
background-jobs
timezone
rule
description
Use cron expressions to schedule periodic tasks at specific calendar times, enabling flexible scheduling beyond simple fixed intervals.
related
scheduling-pattern-repeat-effect-on-fixed-interval
run-background-tasks-with-fork
handle-side-effects-with-effect-sync
author effect_website
lessonOrder 2

Guideline

Use cron expressions for scheduling that aligns with business calendars:

  • Hourly backups: 0 * * * * (at :00 every hour)
  • Daily reports: 0 9 * * 1-5 (9 AM weekdays)
  • Monthly cleanup: 0 0 1 * * (midnight on 1st of month)
  • Business hours: 0 9-17 * * 1-5 (9 AM-5 PM, Mon-Fri)

Format: minute hour day month weekday


Rationale

Fixed intervals don't align with business needs:

Fixed interval (every 24 hours):

  • If task takes 2 hours, next run is 26 hours later
  • Drifts over time
  • No alignment with calendar
  • Fails during daylight saving time changes

Cron expressions:

  • Specific calendar times (e.g., always 9 AM)
  • Independent of execution duration
  • Aligns with business hours
  • Natural DST handling (clock adjusts, cron resyncs)
  • Human-readable vs. milliseconds

Real-world example: Daily report at 9 AM

  • Fixed interval: Scheduled at 9:00, takes 1 hour → next at 10:00 → drift until 5 PM
  • Cron 0 9 * * *: Always runs at 9:00 regardless of duration or previous delays

Good Example

This example demonstrates scheduling a daily report generation using cron, with timezone support.

import { Effect, Schedule, Console } from "effect";
import { DateTime } from "luxon"; // For timezone handling

interface ReportConfig {
  readonly cronExpression: string;
  readonly timezone?: string;
  readonly jobName: string;
}

interface ScheduledReport {
  readonly timestamp: Date;
  readonly jobName: string;
  readonly result: string;
}

// Simple cron parser (in production, use a library like cron-parser)
const parseCronExpression = (
  expression: string
): {
  minute: number[];
  hour: number[];
  dayOfMonth: number[];
  month: number[];
  dayOfWeek: number[];
} => {
  const parts = expression.split(" ");

  const parseField = (field: string, max: number): number[] => {
    if (field === "*") {
      return Array.from({ length: max + 1 }, (_, i) => i);
    }

    if (field.includes(",")) {
      return field.split(",").flatMap((part) => parseField(part, max));
    }

    if (field.includes("-")) {
      const [start, end] = field.split("-").map(Number);
      return Array.from({ length: end - start + 1 }, (_, i) => start + i);
    }

    return [Number(field)];
  };

  return {
    minute: parseField(parts[0], 59),
    hour: parseField(parts[1], 23),
    dayOfMonth: parseField(parts[2], 31),
    month: parseField(parts[3], 12),
    dayOfWeek: parseField(parts[4], 6),
  };
};

// Check if current time matches cron expression
const shouldRunNow = (parsed: ReturnType<typeof parseCronExpression>): boolean => {
  const now = new Date();

  return (
    parsed.minute.includes(now.getUTCMinutes()) &&
    parsed.hour.includes(now.getUTCHours()) &&
    parsed.dayOfMonth.includes(now.getUTCDate()) &&
    parsed.month.includes(now.getUTCMonth() + 1) &&
    parsed.dayOfWeek.includes(now.getUTCDay())
  );
};

// Generate a report
const generateReport = (jobName: string): Effect.Effect<ScheduledReport> =>
  Effect.gen(function* () {
    yield* Console.log(`[REPORT] Generating ${jobName}...`);

    // Simulate report generation
    yield* Effect.sleep("100 millis");

    return {
      timestamp: new Date(),
      jobName,
      result: `Report generated at ${new Date().toISOString()}`,
    };
  });

// Schedule with cron expression
const scheduleWithCron = (config: ReportConfig) =>
  Effect.gen(function* () {
    const parsed = parseCronExpression(config.cronExpression);

    yield* Console.log(
      `[SCHEDULER] Scheduling job: ${config.jobName}`
    );
    yield* Console.log(`[SCHEDULER] Cron: ${config.cronExpression}`);
    yield* Console.log(`[SCHEDULER] Timezone: ${config.timezone || "UTC"}\n`);

    // Create schedule that checks every minute
    const schedule = Schedule.fixed("1 minute").pipe(
      Schedule.untilInputEffect((report: ScheduledReport) =>
        Effect.gen(function* () {
          const isPastTime = shouldRunNow(parsed);

          if (isPastTime) {
            yield* Console.log(
              `[SCHEDULED] ✓ Running at ${report.timestamp.toISOString()}`
            );
            return true; // Stop scheduling
          }

          return false; // Continue scheduling
        })
      )
    );

    // Generate report with cron schedule
    yield* generateReport(config.jobName).pipe(
      Effect.repeat(schedule)
    );
  });

// Demonstrate multiple cron schedules
const program = Effect.gen(function* () {
  console.log(
    `\n[START] Scheduling multiple jobs with cron expressions\n`
  );

  // Schedule examples (note: in real app, these would run at actual times)
  const jobs = [
    {
      cronExpression: "0 9 * * 1-5", // 9 AM weekdays
      jobName: "Daily Standup Report",
      timezone: "America/New_York",
    },
    {
      cronExpression: "0 0 * * *", // Midnight daily
      jobName: "Nightly Backup",
      timezone: "UTC",
    },
    {
      cronExpression: "0 0 1 * *", // Midnight on 1st of month
      jobName: "Monthly Summary",
      timezone: "Europe/London",
    },
  ];

  yield* Console.log("[JOBS] Scheduled:");
  jobs.forEach((job) => {
    console.log(
      `  - ${job.jobName}: ${job.cronExpression} (${job.timezone})`
    );
  });
});

Effect.runPromise(program);

Advanced: Timezone-Aware Cron Scheduling

Handle daylight saving time transitions and different timezones:

interface TimezoneConfig extends ReportConfig {
  readonly timezone: string;
}

const scheduleWithTimezone = (config: TimezoneConfig) =>
  Effect.gen(function* () {
    yield* Console.log(`[TIMEZONE] Scheduling in ${config.timezone}`);

    // Get current time in specified timezone
    const getTimeInTimezone = (): Date => {
      const formatter = new Intl.DateTimeFormat("en-US", {
        timeZone: config.timezone,
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
      });

      const parts = formatter.formatToParts(new Date());
      const date = new Date();

      // Reconstruct date in timezone
      return date;
    };

    // Check if should run (in specified timezone)
    const shouldRunInTimezone = (parsed: ReturnType<typeof parseCronExpression>) => {
      const now = getTimeInTimezone();

      return (
        parsed.minute.includes(now.getMinutes()) &&
        parsed.hour.includes(now.getHours())
      );
    };

    const parsed = parseCronExpression(config.cronExpression);

    // Schedule task
    const schedule = Schedule.fixed("1 minute").pipe(
      Schedule.untilInput((prev: number) => {
        const shouldRun = shouldRunInTimezone(parsed);

        if (shouldRun) {
          yield* Console.log(
            `[TIMEZONE] Running job in ${config.timezone} at ${getTimeInTimezone().toISOString()}`
          );
          return true;
        }

        return false;
      })
    );

    yield* generateReport(config.jobName).pipe(
      Effect.repeat(schedule)
    );
  });

Advanced: Cron with Overlapping Execution Prevention

Prevent multiple instances of the same job from running:

interface CronJobWithLocking {
  readonly jobName: string;
  readonly cronExpression: string;
  readonly maxDurationMs: number;
}

const scheduleWithLocking = (config: CronJobWithLocking) =>
  Effect.gen(function* () {
    let isRunning = false;
    let lastCompletionTime = 0;

    const parsed = parseCronExpression(config.cronExpression);

    yield* Console.log(
      `[LOCKING] Job: ${config.jobName}, max duration: ${config.maxDurationMs}ms`
    );

    // Schedule with lock
    const schedule = Schedule.fixed("1 minute").pipe(
      Schedule.untilInputEffect((report: ScheduledReport) =>
        Effect.gen(function* () {
          const now = Date.now();
          const shouldRun = shouldRunNow(parsed);
          const isStale = now - lastCompletionTime > config.maxDurationMs * 2;

          if (!shouldRun) {
            return false;
          }

          if (isRunning && !isStale) {
            yield* Console.log(
              `[LOCKING] Job already running, skipping duplicate`
            );
            return false;
          }

          if (isStale && isRunning) {
            yield* Console.log(
              `[LOCKING] Previous instance stale, allowing restart`
            );
          }

          isRunning = true;

          try {
            const startTime = Date.now();

            yield* generateReport(config.jobName).pipe(
              Effect.timeout(`${config.maxDurationMs} millis`)
            );

            const duration = Date.now() - startTime;
            lastCompletionTime = Date.now();

            yield* Console.log(
              `[LOCKING] Job completed in ${duration}ms`
            );
          } catch (error) {
            yield* Console.log(
              `[LOCKING] Job failed: ${error}`
            );
          } finally {
            isRunning = false;
          }

          return false; // Continue scheduling
        })
      )
    );

    yield* Schedule.repeat_forever(schedule);
  });

Advanced: Dynamic Cron Expression Changes

Update cron expressions at runtime without restarting:

import { Ref } from "effect";

interface DynamicCronConfig {
  readonly jobName: string;
  readonly initialCron: string;
}

const scheduleDynamicCron = (config: DynamicCronConfig) =>
  Effect.gen(function* () {
    const cronExpression = yield* Ref.make(config.initialCron);

    yield* Console.log(
      `[DYNAMIC] Job: ${config.jobName}, initial cron: ${config.initialCron}`
    );

    // Method to update cron expression
    const updateCronExpression = (newExpression: string) =>
      Effect.gen(function* () {
        const old = yield* Ref.get(cronExpression);
        yield* Ref.set(cronExpression, newExpression);
        yield* Console.log(
          `[DYNAMIC] Updated cron: ${old}${newExpression}`
        );
      });

    // Schedule with dynamic cron
    const schedule = Schedule.fixed("1 minute").pipe(
      Schedule.untilInputEffect((report: ScheduledReport) =>
        Effect.gen(function* () {
          const currentCron = yield* Ref.get(cronExpression);
          const parsed = parseCronExpression(currentCron);

          if (shouldRunNow(parsed)) {
            yield* Console.log(
              `[DYNAMIC] Running with: ${currentCron}`
            );
            return true;
          }

          return false;
        })
      )
    );

    // Run job
    yield* generateReport(config.jobName).pipe(
      Effect.repeat(schedule)
    );
  });

// Example: Update cron every hour
const updateCronPeriodically = (
  jobName: string,
  schedule: { time: string; cron: string }[]
) =>
  Effect.gen(function* () {
    const currentHour = new Date().getHours();
    const nextSchedule = schedule[currentHour % schedule.length];

    // This is a simplified example
    yield* Console.log(
      `[UPDATE] New schedule for hour ${currentHour}: ${nextSchedule.cron}`
    );
  });

Advanced: Cron with Backoff and Retry

Combine cron scheduling with exponential backoff:

interface CronWithRetryConfig extends CronJobWithLocking {
  readonly maxRetries: number;
  readonly baseDelayMs: number;
  readonly maxDelayMs: number;
}

const scheduleCronWithBackoff = (config: CronWithRetryConfig) =>
  Effect.gen(function* () {
    const parsed = parseCronExpression(config.cronExpression);

    yield* Console.log(
      `[CRON+RETRY] Job: ${config.jobName}, max retries: ${config.maxRetries}`
    );

    const schedule = Schedule.fixed("1 minute").pipe(
      Schedule.untilInputEffect((report: ScheduledReport) =>
        Effect.gen(function* () {
          if (!shouldRunNow(parsed)) {
            return false;
          }

          // Try to execute with backoff
          let attempt = 0;
          let lastError: Error | undefined;

          while (attempt < config.maxRetries) {
            try {
              yield* generateReport(config.jobName);
              return false; // Success, continue scheduling
            } catch (error) {
              lastError = error as Error;

              if (attempt < config.maxRetries - 1) {
                const exponential = config.baseDelayMs * Math.pow(2, attempt);
                const delay = Math.min(exponential, config.maxDelayMs);

                yield* Console.log(
                  `[RETRY] Attempt ${attempt + 1} failed, retrying in ${delay}ms`
                );

                yield* Effect.sleep(`${delay} millis`);
              }

              attempt++;
            }
          }

          if (lastError) {
            yield* Console.log(
              `[FAILURE] Job failed after ${config.maxRetries} attempts: ${lastError.message}`
            );
          }

          return false; // Continue scheduling for next cron time
        })
      )
    );

    yield* Schedule.repeat_forever(schedule);
  });

When to Use This Pattern

Use cron expressions when:

  • Running daily/hourly/weekly tasks at specific times
  • Business logic tied to clock time (daily reports at 9 AM)
  • Calendar-based operations (month-end close, quarterly reviews)
  • Need alignment with business hours or timezones
  • Long-term recurring tasks
  • Tasks that should survive application restarts

⚠️ Trade-offs:

  • More complex than fixed intervals
  • Cron parsing can be CPU intensive (cache results)
  • DST transitions require careful handling
  • Timezone library dependencies
  • Distributed systems need coordination (prevent duplicates)

Common Cron Expressions

Expression Meaning
0 0 * * * Every day at midnight
0 9 * * 1-5 9 AM Monday-Friday
0 12 * * 0,6 Noon on weekends
*/15 * * * * Every 15 minutes
0 0 1 * * First day of month
0 0 * * 0 Every Sunday
0 9,17 * * * 9 AM and 5 PM daily
30 2 * * * 2:30 AM daily

See Also