Skip to content

States Reference ​

Every state that a run, step, or schedule can occupy, with transition conditions and terminal definitions.

Run States ​

There is no pending run state. Submit and Enqueue insert the run and its steps in one transaction. A run therefore starts running. The database CHECK constraint on cyclone_runs.state permits only the five values below.

StateValueDescription
RunRunning"running"The run was created and has not reached a terminal state
RunCompleted"completed"All steps completed, or an OnDepFailure(CancelBranch) branch contained every discarded dependency. See Per-Branch Failure Policy
RunFailed"failed"At least one step was discarded, and an OnDepFailure(CancelBranch) policy did not contain its downstream. Cyclone cancelled the other non-terminal steps. The WithRunDeadline scanner also reaches this state when it stops a still-running run. Those steps land in discarded with error_kind = "deadline"
RunCancelled"cancelled"The operator called client.Cancel. Cyclone cancelled all non-terminal steps
RunPaused"paused"The operator called run.Pause. The run starts no new work until run.Resume. It is not terminal. See Pause and Resume Runs
go
switch run.State {
case cyclone.RunCompleted:
    // all done
case cyclone.RunFailed:
    // at least one step discarded; check ListSteps for cyclone.StepExhausted nodes
case cyclone.RunRunning:
    // still in progress
case cyclone.RunCancelled:
    // operator cancelled
case cyclone.RunPaused:
    // operator paused; run.Resume continues it
}

RunState.Terminal reports whether a run state is terminal. Use it instead of a comparison with RunRunning. A paused run is not terminal either.

Step States ​

StateValueDescription
StepPending"pending"The step waits for upstream dependencies to complete. A fan-out node also stays pending while its item tasks execute. An item task stays pending until the WithMaxParallel limit has a free place
StepAvailable"available"Dependencies are met. A worker can claim the step
StepRunning"running"A worker claimed the step. The handler is executing
StepWaitingSignal"waiting_signal"The step waits until a named signal arrives or the max wait ends
StepWaitingTimer"waiting_timer"The step waits until a timer fires
StepWaitingChild"waiting_child"A child node waits for its child run. No worker claims it. No worker holds it
StepPaused"paused"The run is paused. The step was available or waiting at the pause. Or a failed attempt, a snooze, or a reclaim made it available again during the pause. An item task that a fan-out node expands during the pause is also paused. A waiting child node is paused too. No worker claims it. No scanner moves it
StepCompleted"completed"The handler returned nil. Cyclone stored the output
StepExhausted"discarded"Attempts are exhausted, or the handler returned Permanent(err). Or the run's WithRunDeadline elapsed. Then error_kind = "deadline" and error = "run deadline exceeded". Or the lease expired again after WithMaxReclaims reclaims. Then error_kind = "reclaim_limit"
StepCancelled"cancelled"The cause is run cancellation, fail-fast from another discarded node, or branch-cancellation. Branch-cancellation uses OnDepFailure(CancelBranch). In that case, this node's dependency was discarded and this node declared CancelBranch
go
steps, _ := client.ListSteps(ctx, runID)
for _, s := range steps {
    switch s.State {
    case cyclone.StepCompleted:
        // can read output
    case cyclone.StepExhausted:
        fmt.Printf("node %s failed: %s\n", s.Node, s.Error)
    case cyclone.StepRunning:
        fmt.Printf("node %s in-flight (attempt %d)\n", s.Node, s.Attempts+1)
    }
}

Schedule States ​

Schedules have an Enabled boolean field, not a separate state enum. A schedule is either enabled or disabled. There is no state between those two.

client.PauseSchedule sets enabled = false. client.ResumeSchedule sets it back. client.Unschedule is a deprecated alias for pause. It issues the same UPDATE.

sql
// From cyclone_schedules table
SELECT id, kind, enabled, consecutive_failures, next_run_at FROM cyclone_schedules;

A schedule with consecutive_failures > 0 has had a scanner failure, an enqueue failure, or a workflow submit failure. A handler failure after a run is created is reported on the run and the steps, not on the schedule.

Step State Transition Table ​

FromToTrigger
(inserted)pendingSubmitted with upstream dependencies not yet completed
(inserted)availableSubmitted with no dependencies, or no deps for standalone job
pendingavailableLast upstream dependency reaches completed
availablerunningA worker claims the step with FOR UPDATE SKIP LOCKED
runningcompletedHandler returns nil
runningavailableRetryable error and attempt < maxAttempts. scheduled_at is set to now() + backoff
runningavailableThe handler returned cyclone.Snooze(d). scheduled_at is set to now() + d. The attempt is not incremented
pendingwaiting_signalPromotion moves a WaitSignal or WaitAny node straight from pending. No worker claims it. It is never running. No worker executes a wait node
pendingwaiting_timerPromotion moves a WaitTimer node straight from pending and sets scheduled_at = now() + wait_for
waiting_signalcompletedThe signal was consumed, or the WithMaxWait bound lapsed. A lapse is not a re-queue. The node completes with TimedOut true and emits wait_lapsed. A WaitN quorum completes with the payloads it gathered. Downstream steps branch on the value
waiting_timercompletedTimer fired
runningdiscardedThe handler returned Permanent(err), or attempt == maxAttempts. Or the run's WithRunDeadline elapsed. The deadline scanner sets non-terminal steps to discarded with error_kind = "deadline"
runningavailableThe lease expired. The recovery sweep reclaims the step. The attempt is not incremented. reclaims is incremented. There is no backoff
runningdiscardedThe lease expired on a step already reclaimed WithMaxReclaims times. The recovery sweep dead-letters it with error_kind = "reclaim_limit". The attempt is not incremented
runningcancelledFail-fast from another discarded node, or client.Cancel. Or this node's dependency was discarded and this node declared OnDepFailure(CancelBranch). The same triggers apply to waiting_signal and waiting_timer nodes in the cancelled branch
availablecancelledFail-fast, branch-cancellation under CancelBranch, or client.Cancel
pendingcancelledFail-fast, branch-cancellation under CancelBranch, or client.Cancel
waiting_signalcancelledBranch-cancellation under OnDepFailure(CancelBranch), or run-level cancellation (client.Cancel or fail-fast)
waiting_timercancelledThe same triggers as waiting_signal. Cyclone cancels a parked timer with the rest of the branch or the run
discardedavailableclient.Retry or client.RetryAll. The attempt is preserved. max_attempts is raised to at least attempt + RetryAdditionalAttempts. reclaims is reset to 0
availablepausedrun.Pause
waiting_signalpausedrun.Pause. The wait keeps the payloads it gathered and the time it had left
waiting_timerpausedrun.Pause. The timer keeps the time it had left
runningpausedA failed attempt, a snooze, or a reclaim while the run is paused. The step goes to available and then to paused in one transaction
pausedavailablerun.Resume for a step that is not a wait. Its scheduled_at does not change
pausedwaiting_signalrun.Resume for a signal wait. A WithMaxWait limit moves forward by the length of the pause
pausedwaiting_timerrun.Resume for a timer. Its scheduled_at moves forward by the length of the pause
pausedcancelledclient.Cancel, fail-fast, or branch-cancellation under CancelBranch

Child Nodes ​

A child node is never claimed and never running: the engine starts its child run and ends it. These transitions are added:

FromToTrigger
(inserted)waiting_childA child node with no dependencies
pendingwaiting_childThe last dependency of the child node completed. A WithMaxWait limit starts
waiting_childcompletedThe child run completed. The node stores the result of the child run
waiting_childdiscardedThe child run failed, was cancelled, or completed while its result node did not complete. Or it completed with no result node, and the node needs a result. Or the WithMaxWait limit elapsed. Or the child workflow did not build. The error kind is permanent
waiting_childdiscardedThe run deadline of the parent run elapsed. Error kind deadline
waiting_childcancelledclient.Cancel, fail-fast, or branch-cancellation under CancelBranch. The active child run is cancelled in the same transaction
waiting_childpausedrun.Pause. The child run continues
pausedwaiting_childrun.Resume. The WithMaxWait limit keeps the time it had left
discarded, cancelledpendingclient.Retry or client.RetryAll. The node reuses its child run. A failed or cancelled child run starts again

Fan-Out Nodes and Item Tasks ​

A fan-out node is never claimed and never running: the engine expands and finishes it. Its item tasks are ordinary steps and use the transitions above. These transitions are added:

FromToTrigger
(inserted)availableThe engine expands a fan-out node: it inserts its item tasks, available up to the WithMaxParallel limit
(inserted)pausedThe engine expands a fan-out node of a paused run. Item tasks up to the WithMaxParallel limit are paused, not available
(inserted)pendingItem tasks after the WithMaxParallel limit
pendingavailableAn item task of the same fan-out node finished, the limit has a free place, and the run is not paused. A paused item task holds its place in the limit
pendingcompletedFan-out node: its last item task completed, or its list is empty. The output is the item outputs in item order
pendingdiscardedFan-out node: an item task was discarded. The node gets that item task's error kind. Or the list has more than MaxFanOutItems items. Or the collected output is larger than the OutputBytes limit. Or the upstream output is not a list. Those three give error kind permanent
pending, available, running, pausedcancelledItem task: its fan-out node was discarded. Every item task must succeed, so the others stop
discardedpendingFan-out node: client.Retry or client.RetryAll. A retry with the fan-out node ID or an item task ID reopens the node and its discarded item tasks together

Run State Transition Table ​

FromToTrigger
(inserted)runningSubmit or Enqueue called
runningcompletedAll steps reach completed. Or an OnDepFailure(CancelBranch) branch contained every discarded dependency, and no non-terminal step remains
runningfailedA step reaches discarded and OnDepFailure(CancelBranch) does not contain all of its downstream. The default FailRun policy fails the run. One FailRun dependent anywhere in the branch also fails the run. The WithRunDeadline scanner also reaches this state when it stops a still-running run
runningcancelledclient.Cancel called
failedrunningclient.Retry / client.RetryAll reopens discarded nodes
runningpausedrun.Pause called
pausedrunningrun.Resume called
pausedcompletedA step that was in progress at the pause completed the last node. Or a contained discard left no non-terminal step
pausedfailedA step that was in progress at the pause was discarded, and OnDepFailure(CancelBranch) did not contain the discard
pausedcancelledclient.Cancel called

Terminal States ​

A step or a run in a terminal state does not change unless an operator acts on it.

Terminal step states: StepCompleted, StepExhausted, StepCancelled

Terminal run states: RunCompleted, RunFailed, RunCancelled

RunRunning and RunPaused are not terminal. RunState.Terminal gives this classification, and Wait, Signal, Result, and Output use it.

client.Cancel on a terminal run returns ErrAlreadyTerminal. client.Retry on a node that is not an exhausted (discarded) step returns ErrNoExhaustedNode. A retry that reopens a child node whose child run completed with no result for the node returns ErrChildNotRetryable. run.Pause on a terminal run returns ErrAlreadyTerminal. run.Pause on a paused run returns ErrAlreadyPaused. run.Resume on a run that is not paused returns ErrNotPaused.

Retryable States ​

Steps move back to a claimable state in three ways:

  1. Automatic retry: the handler returned a retryable error and attempt < maxAttempts. The step goes from running to available with scheduled_at = now() + backoff(attempt).

  2. Lease reclaim: lease_expiry < now(). The recovery sweep moves the step from running to available. The attempt counter is not incremented. This is a reclaim, not a failure. A reclaim does not apply backoff. It does increment reclaims (StepStatus.Reclaims). With WithMaxReclaims(n), the expiry after the nth reclaim discards the step instead.

  3. Operator retry: client.Retry moves the step from discarded to available. The attempt counter stays as it is. max_attempts is raised to at least attempt + RetryAdditionalAttempts, so the node has executions left. reclaims restarts at 0.

CLI and API State Name Mapping ​

The Go constants use RunState and StepState string types. Each constant's value is exactly the value stored in the database and shown by the CLI. One constant name does not match its value. StepExhausted is stored as "discarded".

go
// Go constant            // DB/CLI value
cyclone.RunRunning          // "running"
cyclone.RunCompleted        // "completed"
cyclone.RunFailed           // "failed"
cyclone.RunCancelled        // "cancelled"
cyclone.RunPaused           // "paused"

cyclone.StepPending         // "pending"
cyclone.StepAvailable       // "available"
cyclone.StepRunning         // "running"
cyclone.StepWaitingSignal   // "waiting_signal"
cyclone.StepWaitingTimer    // "waiting_timer"
cyclone.StepWaitingChild    // "waiting_child"
cyclone.StepCompleted       // "completed"
cyclone.StepExhausted       // "discarded"
cyclone.StepCancelled       // "cancelled"
cyclone.StepPaused          // "paused"

SQL queries can use these string values directly:

sql
SELECT * FROM cyclone_steps WHERE state = 'discarded';
SELECT * FROM cyclone_runs  WHERE state IN ('failed', 'running');

Error Kinds ​

error_kind is a separate axis from state. It classifies the last error recorded on a step. Cyclone shows it as StepStatus.ErrorKind, RunStatus.ErrorKind, StepEvent.ErrorKind, and ExhaustedStep.Reason. There are six values. Each value has an exported Go constant:

Go constantValueWritten whenTerminal?
cyclone.ErrorKindRetryable"retryable"A handler returned an ordinary error, panicked, or its timeout fired. Cyclone retries the step, or discards it once attempts are exhaustedBoth
cyclone.ErrorKindPermanent"permanent"The handler returned Permanent(err), or the output exceeded the payload limit. Cyclone discards the step with no further attempt. A fan-out node that fails a fan-out limit also gets this kind. A child node whose child run gave no result gets it tooYes
cyclone.ErrorKindDeadline"deadline"The run passed its WithRunDeadline cutoff. The scanner then dead-lettered every non-terminal stepYes
cyclone.ErrorKindReclaimLimit"reclaim_limit"The step's lease expired again after WithMaxReclaims reclaims. The recovery sweep dead-lettered it instead of reclaiming it once more. No attempt is chargedYes
cyclone.ErrorKindSnooze"snooze"The handler returned Snooze(d). Cyclone delays that same attempt. No attempt is chargedNo
cyclone.ErrorKindTransient"transient"The step's input could not be read (a database error, not bad data). The step goes back to available at the same attempt ordinalNo

Only retryable, permanent, deadline, and reclaim_limit can appear on a discarded step. Those four values are exactly what cyclone dlq --reason and ExhaustedQuery.Reason accept:

sh
cyclone dlq list --reason deadline
cyclone dlq list --reason reclaim_limit

error_kind describes the last failure, not the current state. A later success does not clear error_kind. It also does not clear error or panic_stack. cyclone retry clears all three when it reopens a node.

Cyclone stores each run in Postgres.