Scheduler Module
The Scheduler module (@saasframe/scheduler) provides a production-ready, database-managed scheduled job system with support for both development (local polling) and production (BullMQ) execution strategies.
Package Location
packages/scheduler/
├── src/
│ ├── modules/
│ │ └── scheduler/
│ │ ├── api/ # REST API routes
│ │ ├── backend/ # Admin UI pages
│ │ ├── commands/ # Undoable commands
│ │ ├── data/ # Entities and validators
│ │ ├── i18n/ # Translations (en, de, es, pl)
│ │ ├── lib/ # Core utilities
│ │ ├── migrations/ # Database migrations
│ │ ├── services/ # Business logic services
│ │ ├── workers/ # Queue workers
│ │ ├── acl.ts # Access control features
│ │ ├── cli.ts # CLI commands
│ │ └── di.ts # DI registration
│ └── index.ts # Package exports
Import Path
import {
ScheduledJob,
SchedulerService,
type ScheduleRegistration,
parseCronExpression,
parseInterval,
calculateNextRun
} from '@saasframe/scheduler'
Key Features
- Database-managed schedules - All configuration stored in PostgreSQL
- Dual execution strategies - Local polling (dev) and BullMQ (prod)
- Cron and interval scheduling - Flexible timing options
- Multi-tenant isolation - System, organization, and tenant scopes
- Command-based mutations - Full undo/redo support
- Event-driven architecture - Consistent event API across strategies
- Feature flag support - RBAC-based access control
- Auto-sync - MikroORM subscriber keeps BullMQ in sync
- Timezone awareness - Execute jobs in any timezone
- Execution history - Track runs and troubleshoot (async mode)
Architecture
Execution Strategies
Local Strategy (QUEUE_STRATEGY=local)
class LocalSchedulerService {
// Polls database every 30s (configurable via SCHEDULER_POLL_INTERVAL_MS)
// Prevents duplicate execution in-process; the PostgreSQL advisory lock
// only serialises the claim, not the run
// Enqueues to target queues or executes commands directly
async start(): Promise<void>
async stop(): Promise<void>
}
Best for:
- Local development
- Single-instance deployments
- Environments without Redis
Limitations:
- ~30 second timing accuracy
- Single instance only
- No execution history UI
QUEUE_STRATEGY=localThe local strategy provides no cross-process protection. Duplicate prevention is
in-process only: the PostgreSQL advisory lock is released as soon as the short claim
transaction commits (holding it across the whole run would pin a connection
idle-in-transaction and get it reaped), and nextRunAt is advanced only after the
target has run. A second process polling the same database therefore sees the schedule
as still due and executes it too — the same job is enqueued twice, or the same command
runs twice.
Two entry points start this engine: saasframe scheduler start and
saasframe worker --with-scheduler. Running both, or overlapping containers during a
rolling redeploy, double-fires every schedule due in that window. Use
QUEUE_STRATEGY=async when more than one instance may run.
Async Strategy (QUEUE_STRATEGY=async)
class BullMQSchedulerService {
// Uses BullMQ repeatable jobs for cron-precision timing
// Distributed locking across multiple instances
// Full execution history and job details
async register(schedule: ScheduledJob): Promise<void>
async unregister(scheduleId: string): Promise<void>
async syncAll(): Promise<void>
}
Best for:
- Production deployments
- Multi-instance scaling
- When execution history is needed
Requires:
- Redis
- Separate worker processes
Core Services
SchedulerService (Public API)
The main service for modules to interact with the scheduler:
import { inject } from 'awilix'
import type { SchedulerService } from '@saasframe/scheduler'
class MyCurrencyService {
constructor(@inject('schedulerService') private schedulerService: SchedulerService) {}
async enableAutoFetch(config: CurrencyFetchConfig) {
await this.schedulerService.register({
id: `currencies:fetch-rates:${config.organizationId}`,
name: 'Fetch Currency Rates',
scopeType: 'organization',
organizationId: config.organizationId,
tenantId: config.tenantId,
scheduleType: 'cron',
scheduleValue: '0 */6 * * *', // Every 6 hours
targetType: 'queue',
targetQueue: 'currency-rates',
targetPayload: { providers: config.providers },
sourceModule: 'currencies',
})
}
async disableAutoFetch(organizationId: string) {
await this.schedulerService.unregister(
`currencies:fetch-rates:${organizationId}`
)
}
}
Methods:
interface SchedulerService {
// Register or update a schedule (upsert)
register(registration: ScheduleRegistration): Promise<void>
// Remove a schedule
unregister(scheduleId: string): Promise<void>
// Check if schedule exists
exists(scheduleId: string): Promise<boolean>
// Update schedule fields
update(scheduleId: string, changes: Partial<ScheduleRegistration>): Promise<void>
// Find schedules by module
findByModule(moduleId: string): Promise<ScheduledJob[]>
// Enable/disable schedule
enable(scheduleId: string): Promise<void>
disable(scheduleId: string): Promise<void>
}
ScheduleRegistration Type:
interface ScheduleRegistration {
id: string // Unique schedule ID (use module:purpose:scope pattern)
name: string // Human-readable name
description?: string // Optional description
scopeType: 'system' | 'organization' | 'tenant'
tenantId?: string // Required for tenant scope
organizationId?: string // Required for organization scope
scheduleType: 'cron' | 'interval'
scheduleValue: string // Cron expression or interval (e.g., "15m")
timezone?: string // Default: UTC
targetType: 'queue' | 'command'
targetQueue?: string // Required if targetType = 'queue'
targetCommand?: string // Required if targetType = 'command'
targetPayload?: Record<string, any> // Optional JSON arguments
requireFeature?: string // Optional feature flag
sourceModule: string // Your module ID
}
Database Schema
ScheduledJob Entity
@Entity({ tableName: 'scheduled_jobs' })
export class ScheduledJob {
@PrimaryKey({ type: 'uuid' })
id!: string
@Property({ nullable: true })
organizationId?: string
@Property({ nullable: true })
tenantId?: string
@Enum(() => ScopeType)
scopeType!: 'system' | 'organization' | 'tenant'
@Property()
name!: string
@Property({ nullable: true })
description?: string
@Enum(() => ScheduleType)
scheduleType!: 'cron' | 'interval'
@Property()
scheduleValue!: string // Cron expression or interval
@Property({ default: 'UTC' })
timezone!: string
@Enum(() => TargetType)
targetType!: 'queue' | 'command'
@Property({ nullable: true })
targetQueue?: string
@Property({ nullable: true })
targetCommand?: string
@Property({ type: 'jsonb', nullable: true })
targetPayload?: Record<string, any>
@Property({ nullable: true })
requireFeature?: string
@Property({ default: true })
isEnabled!: boolean
@Property({ nullable: true })
lastRunAt?: Date
@Property({ nullable: true })
nextRunAt?: Date
@Enum(() => SourceType)
sourceType!: 'user' | 'module'
@Property({ nullable: true })
sourceModule?: string
@Property()
createdAt: Date = new Date()
@Property({ onUpdate: () => new Date() })
updatedAt: Date = new Date()
@Property({ nullable: true })
deletedAt?: Date
@Property({ nullable: true })
createdByUserId?: string
@Property({ nullable: true })
updatedByUserId?: string
}
Indexes:
(organization_id, tenant_id)- Tenant/org filtering(next_run_at)- Efficient polling for due schedules(scope_type, is_enabled)- Active schedule queries
Schedule Types
Cron Expressions
Standard 5-field cron format:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
* * * * *
Examples:
'0 0 * * *' // Daily at midnight
'0 */6 * * *' // Every 6 hours
'*/15 * * * *' // Every 15 minutes
'0 9 * * 1-5' // Weekdays at 9 AM
'0 0 1 * *' // First day of month at midnight
'0 0 * * 0' // Every Sunday at midnight
Validation:
import { validateCron, parseCronExpression } from '@saasframe/scheduler'
const isValid = validateCron('0 0 * * *') // true
const iterator = parseCronExpression('0 0 * * *', 'America/New_York')
const next = iterator.next() // Next occurrence
Interval Format
Simple interval format: <number><unit>
Units:
s- secondsm- minutesh- hoursd- days
Examples:
'30s' // 30 seconds
'15m' // 15 minutes
'2h' // 2 hours
'1d' // 1 day
Validation:
import { validateInterval, parseInterval, intervalToHuman } from '@saasframe/scheduler'
const isValid = validateInterval('15m') // true
const milliseconds = parseInterval('15m') // 900000
const human = intervalToHuman('15m', 'en') // "15 minutes"
Next Run Calculation
The scheduler automatically calculates the next execution time:
import { calculateNextRun } from '@saasframe/scheduler'
// Cron
const nextRun = calculateNextRun(
'cron',
'0 6 * * *',
'America/New_York',
new Date()
)
// Interval
const nextRun = calculateNextRun(
'interval',
'15m',
'UTC',
new Date()
)
How it works:
- Cron: Uses
cron-parserto find the next occurrence after the current time - Interval: Adds the interval duration to the current time (or last run time)
- Timezone: All calculations respect the schedule's timezone
Target Types
Queue Target
Enqueues a job to a worker queue:
await schedulerService.register({
id: 'my-module:my-task',
name: 'My Periodic Task',
scopeType: 'tenant',
tenantId: 'tenant-123',
scheduleType: 'interval',
scheduleValue: '15m',
targetType: 'queue',
targetQueue: 'my-task-queue',
targetPayload: { key: 'value' },
sourceModule: 'my-module',
})
The worker receives:
// workers/my-task.worker.ts
export const metadata = {
queue: 'my-task-queue',
id: 'my-task',
concurrency: 5,
}
export default async function handler(job: Job) {
const { key } = job.data // Access targetPayload
// Process job
}
Command Target
Executes a registered command:
await schedulerService.register({
id: 'my-module:cleanup',
name: 'Daily Cleanup',
scopeType: 'system',
scheduleType: 'cron',
scheduleValue: '0 2 * * *',
targetType: 'command',
targetCommand: 'my-module.cleanup',
targetPayload: { days: 30 },
sourceModule: 'my-module',
})
The command receives:
// commands/cleanup.ts
registerCommand(
'my-module.cleanup',
async (ctx, payload: { days: number }) => {
// Execute cleanup
}
)
Events
The scheduler emits events for monitoring:
// subscribers/scheduler-monitor.ts
export const metadata = {
event: 'scheduler.job.started',
persistent: true,
}
export default async function handler(payload: {
scheduleId: string
scheduleName: string
targetType: 'queue' | 'command'
targetQueue?: string
targetCommand?: string
tenantId?: string
organizationId?: string
}) {
// Log start
console.log(`Schedule ${payload.scheduleName} started`)
}
Available events:
scheduler.job.started- Job execution startedscheduler.job.completed- Job completed successfullyscheduler.job.failed- Job execution failedscheduler.job.skipped- Job was skipped (disabled or missing feature)
Security & Multi-tenancy
Scope Validation
The scheduler enforces scope integrity:
// Tenant scope requires tenantId
await schedulerService.register({
scopeType: 'tenant',
tenantId: 'tenant-123', // Required
// ...
})
// Organization scope requires both
await schedulerService.register({
scopeType: 'organization',
tenantId: 'tenant-123', // Required
organizationId: 'org-456', // Required
// ...
})
// System scope requires neither
await schedulerService.register({
scopeType: 'system',
// tenantId and organizationId must be null
// ...
})
Execution Worker Security
The execute-schedule worker validates scope integrity:
// workers/execute-schedule.worker.ts
const schedule = await em.findOne(ScheduledJob, { id: scheduleId })
// Validate scope matches payload
if (schedule.tenantId !== payload.tenantId) {
throw new Error('Scope mismatch: tenant tampering detected')
}
if (schedule.organizationId !== payload.organizationId) {
throw new Error('Scope mismatch: organization tampering detected')
}
This prevents:
- Cross-tenant data access
- Privilege escalation
- Scope tampering
Feature Flag Enforcement
Schedules can require a feature flag:
await schedulerService.register({
id: 'premium:advanced-reports',
requireFeature: 'reports.advanced', // Optional enforcement
// ...
})
The worker checks:
if (schedule.requireFeature) {
const hasFeature = await rbacService.userHasAllFeatures(
userId,
[schedule.requireFeature],
{ tenantId, organizationId }
)
if (!hasFeature) {
// Skip execution, emit scheduler.job.skipped
return
}
}
CLI Commands
List Schedules
yarn saasframe scheduler list
# Filter by tenant
yarn saasframe scheduler list --tenant tenant-123
# Filter by scope
yarn saasframe scheduler list --scope system
# Filter by enabled status
yarn saasframe scheduler list --enabled true
Show Status
yarn saasframe scheduler status
Shows:
- Execution strategy (local/async)
- Redis connection status (async mode)
- Active schedule count
- Next upcoming runs
Run Schedule Manually
yarn saasframe scheduler run <schedule-id>
Executes a schedule immediately (ignores next_run_at).
Start Scheduler Engine
yarn saasframe scheduler start
Local strategy:
- Starts polling engine (keeps running)
- Polls every 30s (configurable)
- Use Ctrl+C to stop gracefully
Async strategy:
- One-time sync with BullMQ
- Exits after sync
- Workers must be started separately
API Routes
All routes require authentication and appropriate features.
List Schedules
GET /api/scheduler/jobs?page=1&pageSize=20&search=report
Response: {
data: ScheduledJob[],
meta: { total, pageSize, page }
}
Features: scheduler.jobs.view
Create Schedule
POST /api/scheduler/jobs
Content-Type: application/json
{
"name": "Daily Report",
"description": "Generate daily sales report",
"scopeType": "tenant",
"scheduleType": "cron",
"scheduleValue": "0 6 * * *",
"timezone": "America/New_York",
"targetType": "command",
"targetCommand": "reports.generate-daily",
"targetPayload": { "format": "pdf" },
"isEnabled": true
}
Response: { id: "...", ... }
Features: scheduler.jobs.manage
Update Schedule
PUT /api/scheduler/jobs
Content-Type: application/json
{
"id": "schedule-id",
"scheduleValue": "0 12 * * *" // Change to noon
}
Response: { id: "...", ... }
Features: scheduler.jobs.manage
Delete Schedule
DELETE /api/scheduler/jobs
Content-Type: application/json
{ "id": "schedule-id" }
Response: { ok: true }
Features: scheduler.jobs.manage
Trigger Schedule
POST /api/scheduler/trigger
Content-Type: application/json
{ "id": "schedule-id" }
Response: { ok: true, jobId: "..." }
Features: scheduler.jobs.trigger
Requires: QUEUE_STRATEGY=async
Get Execution History
GET /api/scheduler/jobs/{id}/executions?page=1&pageSize=20
Response: {
data: [{
id: "...",
state: "completed",
startedAt: "2024-01-27T10:00:00Z",
completedAt: "2024-01-27T10:00:05Z",
result: { ... },
error: null
}],
meta: { total, pageSize, page }
}
Features: scheduler.jobs.view
Requires: QUEUE_STRATEGY=async
Commands (Undoable)
All scheduler mutations use undoable commands:
// commands/jobs.ts
registerCommand('scheduler.jobs.create', async (ctx, payload) => {
// Create schedule
// Returns after snapshot for undo
})
registerCommand('scheduler.jobs.update', async (ctx, payload) => {
// Update schedule
// Captures before/after snapshots
})
registerCommand('scheduler.jobs.delete', async (ctx, payload) => {
// Soft delete
// Undo: clear deletedAt
})
Undo/Redo:
import { commandBus } from '@saasframe/shared/lib/commands'
// Create
const result = await commandBus.execute('scheduler.jobs.create', payload)
// Undo (soft deletes the schedule)
await commandBus.undo(result.commandId)
// Redo (restores the schedule)
await commandBus.redo(result.commandId)
Testing
Unit Testing
import { parseInterval, validateCron, calculateNextRun } from '@saasframe/scheduler'
describe('Interval Parser', () => {
it('parses valid intervals', () => {
expect(parseInterval('15m')).toBe(900000)
expect(parseInterval('2h')).toBe(7200000)
})
it('rejects invalid intervals', () => {
expect(validateInterval('invalid')).toBe(false)
})
})
Integration Testing
import { bootstrapTest } from '@saasframe/shared/lib/testing/bootstrap'
import type { SchedulerService } from '@saasframe/scheduler'
describe('SchedulerService', () => {
let container: any
let schedulerService: SchedulerService
beforeAll(async () => {
container = await bootstrapTest({ includeDb: true })
schedulerService = container.resolve('schedulerService')
})
it('registers a schedule', async () => {
await schedulerService.register({
id: 'test:my-schedule',
name: 'Test Schedule',
scopeType: 'system',
scheduleType: 'interval',
scheduleValue: '15m',
targetType: 'command',
targetCommand: 'test.noop',
sourceModule: 'test',
})
const exists = await schedulerService.exists('test:my-schedule')
expect(exists).toBe(true)
})
})
Best Practices
1. Use Module-Scoped IDs
// Good
id: `${moduleId}:${purpose}:${scope}`
id: 'currencies:fetch-rates:org-123'
// Bad
id: '1'
id: 'my-job'
2. Set Appropriate Scopes
// System scope - global functionality
scopeType: 'system'
// Example: system health checks, global cleanup
// Organization scope - per-organization logic
scopeType: 'organization'
// Example: organization reports, billing
// Tenant scope - per-tenant isolation
scopeType: 'tenant'
// Example: user notifications, tenant-specific tasks
3. Validate Payloads
import { z } from 'zod'
const payloadSchema = z.object({
format: z.enum(['pdf', 'csv']),
emails: z.array(z.string().email()),
})
await schedulerService.register({
targetPayload: payloadSchema.parse(userInput),
// ...
})
4. Handle Failures Gracefully
// subscribers/scheduler-failure.ts
export const metadata = {
event: 'scheduler.job.failed',
persistent: true,
}
export default async function handler(payload: {
scheduleId: string
scheduleName: string
error: string
}) {
// Log to monitoring
await logger.error('Schedule failed', payload)
// Alert administrators
await alertService.notify({
level: 'error',
message: `Schedule ${payload.scheduleName} failed: ${payload.error}`,
})
}
5. Use Feature Flags for Beta Features
await schedulerService.register({
id: 'beta:ai-reports',
requireFeature: 'reports.ai', // Only runs for users with this feature
// ...
})
6. Clean Up on Module Uninstall
// When deactivating module
export async function deactivate(container: any) {
const schedulerService = container.resolve('schedulerService')
// Find all schedules created by this module
const schedules = await schedulerService.findByModule('my-module')
// Unregister them
for (const schedule of schedules) {
await schedulerService.unregister(schedule.id)
}
}
Deployment Checklist
Development Setup
- ✅ Set
QUEUE_STRATEGY=local(or omit, it's default) - ✅ Run
yarn saasframe scheduler startin a terminal - ✅ Keep the process running during development
Production Setup
- ✅ Set
QUEUE_STRATEGY=async - ✅ Set
REDIS_URL=redis://localhost:6379 - ✅ Run
yarn saasframe scheduler startonce to sync - ✅ Start workers:
yarn saasframe worker:start - ✅ Monitor workers and Redis health
- ✅ Set up alerts for
scheduler.job.failedevents
Migration Guide
From Legacy Cron Jobs
If migrating from system cron jobs:
# Old cron entry
0 6 * * * cd /app && yarn saasframe reports generate-daily
# New scheduler registration
await schedulerService.register({
id: 'reports:generate-daily:system',
name: 'Daily Reports',
scopeType: 'system',
scheduleType: 'cron',
scheduleValue: '0 6 * * *',
targetType: 'command',
targetCommand: 'reports.generate-daily',
sourceModule: 'reports',
})
Benefits:
- Database-managed (no SSH access needed)
- Multi-tenant support
- Undo/redo capability
- Execution history
- Feature flag enforcement
- Admin UI for non-technical users
Troubleshooting
Schedules Not Running
Check scheduler process:
# Local mode
yarn saasframe scheduler status
# Async mode
yarn saasframe worker:list
Check schedule state:
yarn saasframe scheduler list --enabled true
Check logs:
# Local mode
tail -f scheduler.log
# Async mode
tail -f worker.log
High Database Load
Symptom: Polling causes DB load spikes
Solution: Switch to async strategy or increase poll interval:
# .env
QUEUE_STRATEGY=async
# or
SCHEDULER_POLL_INTERVAL_MS=60000 # 1 minute
Missing Executions
Symptom: Jobs don't run at expected times
Causes:
- Scheduler process not running
- Schedule is disabled
- Feature flag missing
- Cron expression error
- Timezone mismatch
Debug:
yarn saasframe scheduler run <schedule-id> # Test manually
Redis Connection Issues
Symptom: Async mode fails to sync
Check:
redis-cli ping # Should return PONG
Solution:
- Verify
REDIS_URLis correct - Check Redis is running
- Check network/firewall rules
Further Reading
- User Guide: Scheduled Jobs - End-user documentation
- CLI Reference - CLI command details
- API Reference - REST API documentation