Run once: a finished deployment can stay RUNNING and block every later deploy of the workflow
Summary
A "Run once" deployment can end up RUNNING with a stopped_time after its run has finished. Every later deploy of the same workflow is then silently ignored: the runner logs Trying to create a workflow that is already running with workflow_definition_id '…'. Ignoring, starts no trigger, and Studio stays on "Deploying" forever. No pod appears in ryaxns-execs.
Seen on staging (26.10.0-rc3, still present in rc5)
| Deployment | Workflow | Created | State | stopped_time | Executions |
|---|---|---|---|---|---|
WorkflowDeployment-1793005256-nz0m3o60 |
My new workflow | 2026-10-26 09:00:56 | RUNNING | 09:01:01 | 6, all COMPLETED |
WorkflowDeployment-1793005108-tgi0iq23 |
Print env | 2026-10-26 08:58:28 | RUNNING | 08:58:31 |
Both came from "Run once". A deploy of "My new workflow" on rc5 at 15:38 was ignored because of the first row.
Cause
stopped_time is only set together with a stopped state (set_stopped_gracefully / set_stopped), so these deployments were stopped and then put back to RUNNING. That is a lost update between two handlers:
is_started(ryax/runner/application/handlers/workflow_deployment_handlers.py, onWorkflowDeployedSuccessfully) loads the deployment. It is not stopped yet, so the #1337 (closed)is_stopped()guard passes.- With "Run once" the trigger runs once and ends within seconds, and
stop(graceful) commitsSTOPPED_GRACEFULLYplusstopped_time. is_startedresumes after awaiting the addon hook and the publish, callsset_running(), and commits. Onlystatechanged in its session, so the result isstate = RUNNINGwith thestopped_timefrom step 2.
Nothing locks the row, so the guard in step 1 cannot see a stop that commits during step 3.
Expected
A deployment that has been stopped is never set back to RUNNING, whatever order the two events arrive in, and the next deploy of the workflow starts.
Fix
- Code: ryax-core MR (linked below).
is_startedtakes a row lock on the deployment, so it either sees the stop or the stop waits for it. - Existing installs: deployments left in this state need
state = 'STOPPED_GRACEFULLY'wherestate = 'RUNNING' and stopped_time is not null. Consider doing this in a migration.