Schedule management

Creating, pausing, resuming, and deleting Flux schedules via CLI, REST, and SDK — plus how the scheduler coordinates across server replicas and why there's no schedule update command.

You have a running server and a registered workflow. This page covers what comes next: create a schedule, check that it fires, pause it during an incident, resume it after, and tear it down when the workflow retires. The scheduler runs inside the server process (flux/server.py::_scheduler_loop); the commands and routes below all act on its database state.

The CLI surface

The flux schedule group exposes seven subcommands (flux/cli.py):

flux schedule create   <workflow_name> <schedule_name>   --cron | --interval-{hours,minutes}
flux schedule list     [--workflow] [--all] [--format simple|json]
flux schedule show     <schedule_id>
flux schedule pause    <schedule_id>
flux schedule resume   <schedule_id>
flux schedule delete   <schedule_id>                    (interactive confirmation)
flux schedule history  <schedule_id>                    [--limit 10]

--format json works on every subcommand. <schedule_id> accepts either the UUID hex or the human-readable name — the server resolves either via _resolve_schedule_id_or_name.

Create

flux schedule create reports.daily_summary daily-9am \
  --cron "0 9 * * MON-FRI" \
  --timezone "America/New_York" \
  --input '{"format": "pdf"}'

--cron and the interval flags are mutually exclusive. For interval schedules use --interval-hours and/or --interval-minutes; the CLI sums them into seconds. When auth is enabled, --run-as <service-account> is required — the server returns 400 otherwise.

List, show, pause, resume, delete

flux schedule list --workflow reports.daily_summary --all
flux schedule show daily-9am --format json
flux schedule pause daily-9am
flux schedule resume daily-9am
flux schedule delete daily-9am          # prompts y/N

list defaults to active-only; --all includes paused and disabled rows. Pause flips status to PAUSED so ScheduleModel.is_due (flux/models.py) returns False; resume restores ACTIVE and recomputes next_run_at. Neither touches history — run_count and failure_count persist across pauses. Delete prompts via click.confirmation_option; once confirmed, the row and its history go in the same transaction.

History

flux schedule history daily-9am --limit 25

Returns the last N executions the schedule dispatched, with scheduled_at, status, optional execution_id, started_at, completed_at, and error_message. See Schedule history for what to do with the output.

There is no flux schedule update

The CLI does not have update. Editing a schedule expression, input payload, or service account from the CLI means flux schedule delete plus flux schedule create — which loses history. To edit in place, use the REST PUT or the SDK’s update_schedule.

REST equivalents

Every CLI subcommand wraps a route on the server (flux/server.py):

CLIREST
createPOST /schedules
listGET /schedules
showGET /schedules/{id}
pausePOST /schedules/{id}/pause
resumePOST /schedules/{id}/resume
deleteDELETE /schedules/{id}
historyGET /schedules/{id}/history
(no CLI)PUT /schedules/{id}

PUT /schedules/{id} takes a ScheduleUpdateRequest (schedule_config, description, input_data, run_as_service_account — all optional) and patches the row in place, preserving run_count, failure_count, and history. It is the only way to edit a schedule without losing history.

Mutating routes require schedule:*:manage. list and show require workflow:{namespace}:{name}:read on the bound workflow. A minimal create over curl:

curl -sS -X POST http://flux:8000/schedules \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $FLUX_API_KEY" \
  -d '{
    "workflow_name": "daily_summary",
    "workflow_namespace": "reports",
    "name": "daily-9am",
    "schedule_config": {
      "type": "cron",
      "cron_expression": "0 9 * * MON-FRI",
      "timezone": "America/New_York"
    },
    "run_as_service_account": "schedules-runner"
  }'

SDK alternative

The async client at flux/client.py::FluxClient mirrors the REST surface, including update_schedule:

import asyncio
from flux.client import FluxClient

async def rotate_input():
    async with FluxClient("http://flux:8000") as client:
        await client.update_schedule(
            "daily-9am",
            {"input_data": {"format": "csv"}},
        )

asyncio.run(rotate_input())

FluxClient is async-only; for sync scripts wrap with asyncio.run or hit the REST surface with httpx directly.

What goes into a schedule

POST /schedules accepts a ScheduleRequest (flux/server.py) with these fields:

schedule_config is one of two shapes:

{ "type": "cron",     "cron_expression": "0 9 * * MON-FRI", "timezone": "America/New_York" }
{ "type": "interval", "interval_seconds": 3600,             "timezone": "UTC" }

timezone must be a real IANA name — "local" is explicitly rejected by Schedule.__init__ (flux/domain/schedule.py). DST behaviour and “missed run” semantics live on Cron and interval semantics. There is no max_runs field and no per-schedule namespace; the schedule inherits its namespace from the workflow.

Multiple server replicas

Every flux start server process starts its own _scheduler_loop, but the loops coordinate through PostgreSQL: each cycle, one replica takes a session-scoped pg_try_advisory_lock and dispatches the due schedules; the other replicas skip the cycle. Run state (last_run_at, next_run_at, run_count) is persisted per fire before the lock is released, so a due schedule is dispatched exactly once per fire time regardless of replica count, and a restarted server does not re-fire schedules that already ran. If the lock-holding replica dies mid-cycle, its database connection drops, PostgreSQL releases the lock automatically, and a surviving replica picks up the next cycle.

There is no leader to configure and no scheduler-disable flag needed — the coordination is per-cycle and automatic. The one prerequisite is PostgreSQL: on SQLite there is no cross-process lock, but SQLite is a single-node backend anyway. See High availability for the full multi-replica deployment shape.

What can go wrong

Schedule created but never fires

Symptom. flux schedule show <id> returns the row but last_run_at stays Never past the expected first run.

Fixes. Three causes, in order of frequency:

  1. The scheduler loop crashed. Check server logs for Error in scheduler cycle — the loop catches and logs, then keeps polling, so an error log without a subsequent recovery is the signal.
  2. No eligible worker. The scheduler enqueues; a worker still has to claim. Run flux worker list — at least one worker must be connected, and if the workflow uses requests= or affinity=, a matching worker must exist.
  3. The bound workflow was deleted. The scheduler skips it silently.

Duplicate runs

Symptom. Every run produces two executions a fraction of a second apart, both completing.

Cause. On current Flux this should not happen from replica count alone — the dispatch cycle is advisory-lock-guarded and run state is persisted, so N replicas still fire once. The remaining real causes: two separate Flux deployments pointing at the same database (each with its own schedule rows or its own database user racing an out-of-band copy of the schedule), a duplicate schedule (flux schedule list --all showing both nightly and nightly_auto bound to the same workflow — auto-scheduling creates <workflow>_auto on registration), or a pre-0.5x server version that predates the coordination.

Fix. Check flux schedule list --all for a duplicate row first. Then confirm every server process pointing at the database runs the same, current Flux version.

Trying to update an existing schedule

Symptom. flux schedule update ... exits with Error: No such command 'update'.

Fix. Either flux schedule delete then flux schedule create (loses history), or PUT /schedules/{id} via curl or FluxClient.update_schedule (preserves history, run_count, failure_count).

Next