Cron Scheduler
Grit uses the asynq Scheduler for cron-like recurring task execution. Schedule periodic cleanup jobs, report generation, or any recurring work using standard cron expressions. Tasks are enqueued into the same Redis-backed job queue used by background jobs.
How It Works
The cron scheduler is a separate component from the job worker. While the worker processes jobs from the queue, the scheduler periodically enqueues new tasks according to cron expressions. Both share the same Redis connection and task types.
┌────────────────────┐ ┌─────────┐ ┌──────────────────┐ │ Cron Scheduler │ │ Redis │ │ Worker │ │ │ │ Queue │ │ │ │ Every hour: │ │ │ │ │ │ enqueue │────>│ task │────>│ handleTokens │ │ tokens:cleanup │ │ │ │ Cleanup() │ │ │ │ │ │ │ │ Custom schedule: │ │ │ │ │ │ enqueue │────>│ task │────>│ handleCustom() │ │ report:generate │ │ │ │ │ └────────────────────┘ └─────────┘ └──────────────────┘
Scheduler Service
The scheduler lives at internal/cron/cron.go. It wraps the asynq Scheduler and registers cron tasks at creation time.
// Task represents a registered cron task for display purposes.
type Task struct {
Name string `json:"name"`
Schedule string `json:"schedule"`
Type string `json:"type"`
}
// RegisteredTasks holds the list of cron tasks for the admin API.
var RegisteredTasks []Task
// Scheduler wraps asynq.Scheduler for cron-like job scheduling.
type Scheduler struct {
scheduler *asynq.Scheduler
}
// New creates a new cron Scheduler connected to Redis.
func New(redisURL string) (*Scheduler, error)
// Start begins executing scheduled tasks (runs in a goroutine).
func (s *Scheduler) Start() error
// Stop shuts down the scheduler gracefully.
func (s *Scheduler) Stop()Built-in Tasks
Grit ships with one built-in cron task: cleanup expired tokens, which runs every hour. This removes soft-deleted user records older than 30 days.
| Task | Schedule | Type | Description |
|---|---|---|---|
| Cleanup expired tokens | 0 * * * * | tokens:cleanup | Every hour, on the hour |
func New(redisURL string) (*Scheduler, error) {
redisOpt, err := asynq.ParseRedisURI(redisURL)
if err != nil {
return nil, fmt.Errorf("parsing redis URL for cron: %w", err)
}
scheduler := asynq.NewScheduler(redisOpt, nil)
// Register built-in cron tasks
RegisteredTasks = []Task{}
// Cleanup expired tokens -- every hour
_, err = scheduler.Register("0 * * * *", asynq.NewTask("tokens:cleanup", nil))
if err != nil {
return nil, fmt.Errorf("registering tokens cleanup: %w", err)
}
RegisteredTasks = append(RegisteredTasks, Task{
Name: "Cleanup expired tokens",
Schedule: "0 * * * *",
Type: "tokens:cleanup",
})
// grit:cron-tasks
return &Scheduler{scheduler: scheduler}, nil
}Adding Custom Cron Tasks
Add custom cron tasks by registering them in the New() function. Use standard cron expressions (5-field format). The task must have a corresponding handler registered in the job worker.
// Add after the "// grit:cron-tasks" marker:
// Generate daily reports -- every day at midnight
_, err = scheduler.Register("0 0 * * *", asynq.NewTask("report:daily", nil))
if err != nil {
return nil, fmt.Errorf("registering daily report: %w", err)
}
RegisteredTasks = append(RegisteredTasks, Task{
Name: "Generate daily reports",
Schedule: "0 0 * * *",
Type: "report:daily",
})
// Send weekly digest -- every Monday at 9 AM
payload, _ := json.Marshal(map[string]string{"type": "weekly"})
_, err = scheduler.Register("0 9 * * 1", asynq.NewTask("email:digest", payload))
if err != nil {
return nil, fmt.Errorf("registering weekly digest: %w", err)
}
RegisteredTasks = append(RegisteredTasks, Task{
Name: "Send weekly digest",
Schedule: "0 9 * * 1",
Type: "email:digest",
})Cron Expression Reference
| Expression | Schedule |
|---|---|
| * * * * * | Every minute |
| 0 * * * * | Every hour |
| 0 0 * * * | Every day at midnight |
| 0 9 * * 1 | Every Monday at 9 AM |
| */5 * * * * | Every 5 minutes |
| 0 0 1 * * | First day of every month |
The grit:cron-tasks Marker
The // grit:cron-tasks comment in cron.go is a marker used by the code generator. When you use grit add cron or future CLI extensions, new cron tasks are injected at this marker position. You can add tasks manually either above or below it -- just do not remove the marker.
// ... built-in tasks above ...
// grit:cron-tasks <-- CLI injects new tasks here
return &Scheduler{scheduler: scheduler}, nil
}Admin Cron Viewer
The admin panel shows all registered cron tasks via the GET /api/admin/cron/tasks endpoint. This endpoint reads from the RegisteredTasks slice, which is populated during scheduler initialization.
// CronHandler handles admin cron task endpoints.
type CronHandler struct{}
// ListTasks returns all registered cron tasks.
func (h *CronHandler) ListTasks(c *gin.Context) {
tasks := cron.RegisteredTasks
if tasks == nil {
tasks = []cron.Task{}
}
c.JSON(http.StatusOK, gin.H{
"data": tasks,
})
}
// Response example:
// {
// "data": [
// {
// "name": "Cleanup expired tokens",
// "schedule": "0 * * * *",
// "type": "tokens:cleanup"
// }
// ]
// }Lifecycle
The scheduler is started in main.go after the worker, and stopped during graceful shutdown. It runs as a goroutine alongside the HTTP server.
// Create and start the cron scheduler
cronScheduler, err := cron.New(cfg.RedisURL)
if err != nil {
log.Printf("Warning: Cron scheduler failed to start: %v", err)
} else {
cronScheduler.Start()
defer cronScheduler.Stop()
log.Println("Cron scheduler started")
}