Initial commit
This commit is contained in:
417
.agents/skills/building-with-medusa/reference/scheduled-jobs.md
Normal file
417
.agents/skills/building-with-medusa/reference/scheduled-jobs.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# 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](#when-to-use-scheduled-jobs)
|
||||
- [Creating a Scheduled Job](#creating-a-scheduled-job)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Executing Workflows in Scheduled Jobs](#executing-workflows-in-scheduled-jobs)
|
||||
- [Cron Expression Examples](#cron-expression-examples)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## 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](subscribers-and-events.md) 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:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
// ❌ 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.
|
||||
|
||||
```typescript
|
||||
// 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`
|
||||
|
||||
```typescript
|
||||
// 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](https://crontab.guru) to build and validate cron expressions.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Logging
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
// ❌ 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:
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user