Scheduler
Redelay includes a distributed scheduler (go-modules/scheduler) that ensures only one replica dispatches a schedule at a time, using MongoDB for persistence and a Redis lock for leader election.
A schedule does not run code. It publishes an event — event_type.event_action — on a cron expression or once at a fixed time. Any module consumer or FlowDSL flow subscribed to that topic reacts to it. The scheduler decides when; the event bus decides what happens next.
Schedule types
| Type | Fields | Description |
|---|---|---|
| Recurring | cron_expression + optional timezone | 5-field cron expression (0 9 * * 1-5) or a descriptor (@daily, @hourly, @every 1h), read in the schedule's own IANA timezone (default UTC) |
| One-time | one_time: true + execution_time | Fires once at the given time, then moves to the terminal completed status |
Six-field (seconds) cron expressions are rejected: the parser does not enable seconds, so a sixth field would silently shift every position.
A failed publish is retried up to max_retries times, retry_delay seconds apart, before the schedule waits for its next occurrence.
Enabling the scheduler
// Every binary that should FIRE schedules (the engine; mounts no routes):
import _ "github.com/redelay/go-modules/scheduler"
// The admin-api binary only — schedule management HTTP API:
import _ "github.com/redelay/go-modules/scheduler/admin"
// Optional — FlowDSL nodes (cron/interval triggers, create/delete schedule):
import _ "github.com/redelay/go-modules/scheduler/flowdsl"
Python equivalent coming soon.
The core scheduler module is engine-only: it runs the polling loop and publishes events, but mounts no HTTP routes. Managing schedules is an admin capability — a schedule publishes an arbitrary event as an arbitrary actor — so the CRUD API lives in the scheduler/admin submodule, imported by the admin-api binary and gated on the admin:access permission.
Reacting to a scheduled event
A module reacts to a schedule the same way it reacts to any other event — by consuming its topic. A schedule with event_type: "report" and event_action: "generate" publishes to the topic report.generate:
// consumers.go
func (m *Module) Consumers() []*modules.ConsumerRegistration {
return []*modules.ConsumerRegistration{
{
Topic: "report.generate",
GroupID: "reports-generate",
Handler: m.handleGenerateReport,
},
}
}
func (m *Module) handleGenerateReport(ctx context.Context, event *transport.EventMessage) error {
// event.Payload is the schedule's event_payload (JSON).
// event.Headers["schedule_id"] identifies the schedule that fired.
var payload struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return fmt.Errorf("reports: parse report.generate: %w", err)
}
return m.Service.GenerateReport(ctx, payload.Kind)
}
Python equivalent coming soon.
In a flow, use the generic redelay/event-source node with eventName: report.generate instead.
Creating a schedule
Schedules are stored in MongoDB and managed at runtime through the admin API — no redeploy needed:
curl -X POST "$ADMIN_API/api/v1/schedules/" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "nightly-report",
"event_type": "report",
"event_action": "generate",
"event_payload": {"kind": "daily"},
"cron_expression": "0 3 * * *",
"timezone": "Europe/Warsaw",
"max_retries": 3,
"retry_delay": 60,
"tags": ["reports"]
}'
A one-time schedule replaces cron_expression/timezone with "one_time": true, "execution_time": "2026-10-01T09:00:00Z". Invalid timing (missing cron, unparseable expression, unknown timezone) is rejected with 422.
| Method | Path | Purpose |
|---|---|---|
| GET | /schedules/ | List schedules (filter by enabled, tag, event_type, event_action) |
| POST | /schedules/ | Create a schedule |
| GET / PUT / DELETE | /schedules/{schedule_id} | Get, partially update, delete |
| POST | /schedules/{schedule_id}/execute | Fire now, without shifting the regular timing |
| GET | /schedules/{schedule_id}/executions | Execution history (newest first) |
| POST | /schedules/{schedule_id}/pause · /resume | Set state explicitly (idempotent — use in scripts) |
| POST | /schedules/{schedule_id}/toggle | Flip enabled/paused (for UI switches) |
Leader election
Every replica polls, but only the holder of a Redis lock dispatches. The lock key is namespaced per application — redelay:<app-name>:scheduler:leader, derived from APP_NAME — so separate apps (or a stale local go run) sharing one Redis never dispatch each other's schedules. The lock stores its holder (host/pid-N) and is renewed only while still owned; if the leader dies, another replica takes over once the lock TTL expires. Without Redis there is no election and the single process dispatches unconditionally.
Configure via environment:
REDIS_URL=redis://localhost:6379
SCHEDULER_POLL_INTERVAL_SECONDS=10 # how often to check for due schedules
SCHEDULER_LOCK_TTL_SECONDS=30 # leader lock TTL
SCHEDULER_LOG_TTL_DAYS=30 # execution history retention (0 = forever)
# SCHEDULER_LOCK_KEY=... # override the per-app lock key
See the scheduler reference for the full environment table, lock troubleshooting, health checks and the FlowDSL nodes.