Files
2026-03-07 11:07:45 -03:00

12 KiB

Scheduled Jobs

Scheduled jobs are asynchronous functions that run automatically at specified intervals during the Medusa application's runtime. Use them for tasks like syncing products to third-party services, sending periodic reports, or cleaning up stale data.

Contents

When to Use Scheduled Jobs

Use scheduled jobs when you need to perform actions periodically:

  • Syncing data with third-party services on a schedule
  • Sending periodic reports (daily, weekly)
  • Cleaning up stale data (expired carts, old sessions)
  • Generating batch exports
  • Recalculating aggregated data

Don't use scheduled jobs for:

  • Reacting to events (use subscribers instead)
  • One-time tasks (use workflows directly)
  • Real-time processing (use API routes + workflows)

Scheduled Jobs vs Subscribers:

  • Scheduled Job: Finds carts updated >24h ago and sends emails (polling pattern)
  • Subscriber: Reacts to order.created and sends an email (event-driven)

For most use cases, subscribers are preferred when you need to react to specific events.

Creating a Scheduled Job

Create a TypeScript file in the src/jobs/ directory:

// src/jobs/sync-products.ts
import { MedusaContainer } from "@medusajs/framework/types"

export default async function syncProductsJob(container: MedusaContainer) {
  const logger = container.resolve("logger")

  logger.info("Starting product sync...")

  // Resolve services from container
  const productService = container.resolve("product")
  const myService = container.resolve("my-custom-service")

  try {
    // Your job logic here
    const products = await productService.listProducts({ active: true })

    for (const product of products) {
      // Process each product
      await myService.syncToExternalSystem(product)
    }

    logger.info("Product sync completed successfully")
  } catch (error) {
    logger.error(`Product sync failed: ${error.message}`)
    // Don't throw - let the job complete and retry on next schedule
  }
}

export const config = {
  name: "sync-products-daily", // Unique name for the job
  schedule: "0 0 * * *", // Cron expression: midnight daily
}

Configuration Options

export const config = {
  name: "my-job", // Required: unique identifier
  schedule: "* * * * *", // Required: cron expression
  numberOfExecutions: 3, // Optional: limit total scheduled executions
}

Configuration Properties

  • name (required): Unique identifier for the job across your application
  • schedule (required): Cron expression defining when to run
  • numberOfExecutions (optional): Maximum number of times to execute the job according to its schedule

⚠️ CRITICAL - Understanding numberOfExecutions:

numberOfExecutions limits how many times the job runs on its schedule, NOT immediately on server start.

// ❌ WRONG UNDERSTANDING: This will NOT run immediately on server start
export const config = {
  name: "test-job",
  schedule: "0 0 * * *", // Daily at midnight
  numberOfExecutions: 1, // Will run ONCE at the next midnight, not now!
}

// ✅ CORRECT: To test a job immediately, use a frequent schedule
export const config = {
  name: "test-job",
  schedule: "* * * * *", // Every minute
  numberOfExecutions: 1, // Will run once at the next minute
}

// ✅ CORRECT: Testing with multiple runs
export const config = {
  name: "test-job",
  schedule: "*/5 * * * *", // Every 5 minutes
  numberOfExecutions: 3, // Will run 3 times (at 0, 5, 10 minutes), then stop
}

Key points:

  • The job waits for the first scheduled time before executing
  • numberOfExecutions: 1 with a daily schedule means it runs once the next day
  • To test immediately, use a frequent schedule like "* * * * *" (every minute)
  • After reaching numberOfExecutions, the job stops running permanently

Executing Workflows in Scheduled Jobs

⚠️ BEST PRACTICE: Use workflows for mutations in scheduled jobs. This ensures proper error handling and rollback capabilities.

// src/jobs/send-weekly-newsletter.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendNewsletterWorkflow } from "../workflows/send-newsletter"

export default async function sendNewsletterJob(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")

  logger.info("Sending weekly newsletter...")

  try {
    // Query for data
    const { data: customers } = await query.graph({
      entity: "customer",
      fields: ["id", "email"],
      filters: {
        newsletter_subscribed: true,
      },
    })

    logger.info(`Found ${customers.length} subscribers`)

    // Execute workflow
    await sendNewsletterWorkflow(container).run({
      input: {
        customer_ids: customers.map((c) => c.id),
      },
    })

    logger.info("Newsletter sent successfully")
  } catch (error) {
    logger.error(`Newsletter job failed: ${error.message}`)
  }
}

export const config = {
  name: "send-weekly-newsletter",
  schedule: "0 0 * * 0", // Every Sunday at midnight
}

Cron Expression Examples

Cron format: minute hour day-of-month month day-of-week

// Every minute
schedule: "* * * * *"

// Every 5 minutes
schedule: "*/5 * * * *"

// Every hour at minute 0
schedule: "0 * * * *"

// Every day at midnight (00:00)
schedule: "0 0 * * *"

// Every day at 2:30 AM
schedule: "30 2 * * *"

// Every Sunday at midnight
schedule: "0 0 * * 0"

// Every Monday at 9 AM
schedule: "0 9 * * 1"

// First day of every month at midnight
schedule: "0 0 1 * *"

// Every weekday (Mon-Fri) at 6 PM
schedule: "0 18 * * 1-5"

// Every 6 hours
schedule: "0 */6 * * *"

Tip: Use crontab.guru to build and validate cron expressions.

Best Practices

1. Always Use Logging

export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve("logger")

  logger.info("Job started")

  try {
    // Job logic
    logger.info("Job completed successfully")
  } catch (error) {
    logger.error(`Job failed: ${error.message}`, { error })
  }
}

2. Handle Errors Gracefully

Don't throw errors at the top level - log them and let the job complete:

// ❌ BAD: Throws and stops execution
export default async function myJob(container: MedusaContainer) {
  const service = container.resolve("my-service")
  const items = await service.getItems() // Might throw
  // Job stops if this throws
}

// ✅ GOOD: Catches errors and logs
export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve("logger")

  try {
    const service = container.resolve("my-service")
    const items = await service.getItems()
    // Process items
  } catch (error) {
    logger.error(`Job failed: ${error.message}`)
    // Job completes, will retry on next schedule
  }
}

3. Make Jobs Idempotent

Design jobs to be safely re-runnable:

// ✅ GOOD: Idempotent job
export default async function syncProducts(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const myService = container.resolve("my-service")

  // Check what's already synced
  const lastSyncTime = await myService.getLastSyncTime()

  // Only sync products updated since last sync
  const { data: products } = await query.graph({
    entity: "product",
    filters: {
      updated_at: { $gte: lastSyncTime },
    },
  })

  // Sync products (upsert, don't insert)
  for (const product of products) {
    await myService.upsertToExternalSystem(product)
  }

  // Update last sync time
  await myService.setLastSyncTime(new Date())
}

4. Use Workflows for Mutations

// ✅ GOOD: Uses workflow for mutations
import { deleteCartsWorkflow } from "../workflows/delete-carts"

export default async function cleanupExpiredCarts(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")

  // Find expired carts
  const { data: carts } = await query.graph({
    entity: "cart",
    fields: ["id"],
    filters: {
      updated_at: {
        $lte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
      },
    },
  })

  logger.info(`Found ${carts.length} expired carts`)

  // Use workflow for deletion (import at top of file)
  await deleteCartsWorkflow(container).run({
    input: {
      cart_ids: carts.map((c) => c.id),
    },
  })

  logger.info("Expired carts cleaned up")
}

5. Add Metrics/Monitoring

export default async function myJob(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const startTime = Date.now()

  try {
    // Job logic
    const processed = 100 // Track what you processed

    const duration = Date.now() - startTime
    logger.info(`Job completed: ${processed} items in ${duration}ms`)
  } catch (error) {
    logger.error(`Job failed after ${Date.now() - startTime}ms`)
  }
}

6. Test with Limited Executions

When testing, use a frequent schedule with limited executions:

// ✅ CORRECT: Frequent schedule for immediate testing
export const config = {
  name: "test-job",
  schedule: "* * * * *", // Every minute
  numberOfExecutions: 3, // Run 3 times (next 3 minutes), then stop
}

// ❌ WRONG: This won't help with testing
export const config = {
  name: "test-job",
  schedule: "0 0 * * *", // Daily at midnight
  numberOfExecutions: 1, // Will only run ONCE at next midnight, not useful for testing
}

Remember: numberOfExecutions doesn't make the job run immediately - it limits how many times it runs on its schedule.

Complete Example: Abandoned Cart Email Job

// src/jobs/send-abandoned-cart-emails.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email"

export default async function abandonedCartEmailJob(
  container: MedusaContainer
) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")

  logger.info("Starting abandoned cart email job...")

  try {
    // Find carts updated more than 24 hours ago that haven't completed
    const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)

    const { data: carts } = await query.graph({
      entity: "cart",
      fields: ["id", "email", "customer_id"],
      filters: {
        updated_at: {
          $lte: twentyFourHoursAgo,
        },
        completed_at: null,
        email: { $ne: null }, // Must have email
      },
    })

    logger.info(`Found ${carts.length} abandoned carts`)

    // Process in batches
    for (const cart of carts) {
      try {
        await sendAbandonedCartEmailWorkflow(container).run({
          input: {
            cart_id: cart.id,
            email: cart.email,
          },
        })
        logger.info(`Sent email for cart ${cart.id}`)
      } catch (error) {
        logger.error(`Failed to send email for cart ${cart.id}: ${error.message}`)
        // Continue with other carts
      }
    }

    logger.info("Abandoned cart email job completed")
  } catch (error) {
    logger.error(`Abandoned cart job failed: ${error.message}`)
  }
}

export const config = {
  name: "send-abandoned-cart-emails",
  schedule: "0 */6 * * *", // Every 6 hours
}