Concepts

Scheduler

Redelay's distributed cron scheduler with MongoDB leader election.

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

TypeFieldsDescription
Recurringcron_expression + optional timezone5-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-timeone_time: true + execution_timeFires 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

goGo
// 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"

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:

goGo
// 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)
}

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:

shell
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.

MethodPathPurpose
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}/executeFire now, without shifting the regular timing
GET/schedules/{schedule_id}/executionsExecution history (newest first)
POST/schedules/{schedule_id}/pause · /resumeSet state explicitly (idempotent — use in scripts)
POST/schedules/{schedule_id}/toggleFlip 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:

shell
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.