Skill 14 · Building With Medusa
Subchapter 14.9
reference/scheduled-jobs.mdMarkdown12 KBView on GitHub
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.
Use scheduled jobs when you need to perform actions periodically:
Don’t use scheduled jobs for:
Scheduled Jobs vs Subscribers:
order.created and sends an email (event-driven)For most use cases, subscribers are preferred when you need to react to specific events.
Create a TypeScript file in the src/jobs/ directory:
// src/jobs/sync-products.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
export default async function syncProductsJob(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
logger.info("Starting product sync...")
// Resolve services from container
const productService = container.resolve(Modules.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
}export const config = {
name: "my-job", // Required: unique identifier
schedule: "* * * * *", // Required: cron expression
numberOfExecutions: 3, // Optional: limit total scheduled executions
}⚠️ 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:
numberOfExecutions: 1 with a daily schedule means it runs once the next day"* * * * *" (every minute)numberOfExecutions, the job stops running permanently⚠️ 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"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function sendNewsletterJob(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.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 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 (opens in a new tab) to build and validate cron expressions.
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function myJob(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
logger.info("Job started")
try {
// Job logic
logger.info("Job completed successfully")
} catch (error) {
logger.error(`Job failed: ${error.message}`, { error })
}
}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(ContainerRegistrationKeys.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
}
}Design jobs to be safely re-runnable:
// ✅ GOOD: Idempotent job
export default async function syncProducts(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.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())
}// ✅ GOOD: Uses workflow for mutations
import { deleteCartsWorkflow } from "../workflows/delete-carts"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function cleanupExpiredCarts(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.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")
}import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function myJob(container: MedusaContainer) {
const logger = container.resolve(ContainerRegistrationKeys.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`)
}
}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.
// src/jobs/send-abandoned-cart-emails.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function abandonedCartEmailJob(
container: MedusaContainer
) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.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
}