You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#2457 gave automation runs durable records and safe retries, but only forecast runs are actually dispatched a second time. Schedule runs are recorded, claimed, reported and shown like any other, and then left where they failed. This issue is about lifting that restriction.
The gate is one clause in get_dispatchable_automation_runs (flexmeasures/data/services/automations.py):
# Only a forecast run can be dispatched a second time safely.# Its jobs carry IDs derived from the run, so a retry recognizes the ones it already queued.# A schedule run's jobs get a fresh ID on every dispatch, so retrying one would duplicate its schedules,# which is why such a run is recorded and reported, but left where it failed.AutomationRun.automation_type=="forecasting",
Why the restriction exists
The whole of #2457's retry safety rests on one property: a job's RQ ID is a function of the run and a logical key, rather than a random UUID. TrainPredictPipeline._plan_cycle_jobs builds IDs like automation-run-12-cycle-001 and automation-run-12-wrap-up, records an AutomationRunJob intent per job before enqueueing any of them, and on a retry asks Redis whether each ID already exists (reconcile_automation_job_intent). That is what separates "a retry resumes" from "a retry duplicates".
Scheduling has no equivalent. create_scheduling_job takes job_id: str | None = None and hands it to Job.create; nothing in the automation path sets it, so RQ invents a UUID. Two dispatches of the same run produce two unrelated IDs, and there is no way to tell an already-queued schedule from a missing one.
@job_cache("scheduling") is not a substitute. It dedupes on an argument hash with a Redis TTL (FLEXMEASURES_JOB_CACHE_TTL, one hour by default), so it is gone exactly when a run outlives its claim lease or Redis restarts — which is the failure mode durable runs exist to survive. It also hashes the call arguments, so anything that varies between attempts breaks it: AutomationRun.parameters is a snapshot of the automation's raw parameters (see claim_due_automation_run), so a schedule automation that omits start resolves a fresh one per attempt via prepare_schedule_trigger_message and hashes differently. And per #2404 the cache can hand back a wedged job rather than a fresh one.
What needs to happen
Give each device job its own ID.create_sequential_scheduling_job currently forwards a single job_id to every create_scheduling_job call in its loop (flexmeasures/data/services/scheduling.py:514). That is harmless today because every caller passes None; the moment a real ID is passed, all device jobs collide on it. This needs a per-device ID before anything else can be built on top.
Give the wrap-up job an ID. The final cb_done_sequential_scheduling_job job is built with a bare Job.create and has no job_id parameter at all.
Register job intents for schedule jobs, with logical keys along the lines of device-<sensor id> and wrap-up, and queue="scheduling", so the existing ensure_automation_run_job_intents / mark_automation_job_queued / reconcile_automation_job_intent machinery applies unchanged.
Rebuild the depends_on chain on a resumed dispatch against the jobs that already exist, instead of re-creating the chain from scratch.
Record schedule job outcomes against the run. Today only the forecasting pipeline calls record_automation_job_started / _succeeded / _failed, so a schedule run's execution_state never leaves pending and the Automations page shows it that way indefinitely. This is arguably the more visible half of the gap, and is worth doing even if 1–4 are deferred.
Then remove the gate and its test (test_only_a_forecast_run_is_dispatched_a_second_time), and update documentation/features/automations.rst and both changelogs, which currently spell the restriction out.
Notes
The fallback scheduler is not a constraint here. Since Feat/retire fallback scheduler #2252 no core scheduler sets fallback_scheduler_class, so trigger_optional_fallback never fires in core; the hook survives for plugins that set it. The only thing to watch is that a fallback job, which inherits the original's trigger meta and is created with force_new_job_creation=True, must not be handed the deterministic ID of the job it replaces.
Steps 1 and 2 change shared scheduling code that the API trigger endpoint, flexmeasures add schedule and any plugin scheduler all go through, so they deserve their own tests independent of automations.
Acceptance criteria
A schedule automation run whose dispatch fails after queueing some of its device jobs is picked up again, and queues only the jobs it still owes.
A schedule run's execution state reflects what its jobs did, rather than staying pending.
Re-dispatching a schedule run never produces a second schedule for a device that already had one queued for that run.
Summary
#2457 gave automation runs durable records and safe retries, but only forecast runs are actually dispatched a second time. Schedule runs are recorded, claimed, reported and shown like any other, and then left where they failed. This issue is about lifting that restriction.
The gate is one clause in
get_dispatchable_automation_runs(flexmeasures/data/services/automations.py):Why the restriction exists
The whole of #2457's retry safety rests on one property: a job's RQ ID is a function of the run and a logical key, rather than a random UUID.
TrainPredictPipeline._plan_cycle_jobsbuilds IDs likeautomation-run-12-cycle-001andautomation-run-12-wrap-up, records anAutomationRunJobintent per job before enqueueing any of them, and on a retry asks Redis whether each ID already exists (reconcile_automation_job_intent). That is what separates "a retry resumes" from "a retry duplicates".Scheduling has no equivalent.
create_scheduling_jobtakesjob_id: str | None = Noneand hands it toJob.create; nothing in the automation path sets it, so RQ invents a UUID. Two dispatches of the same run produce two unrelated IDs, and there is no way to tell an already-queued schedule from a missing one.@job_cache("scheduling")is not a substitute. It dedupes on an argument hash with a Redis TTL (FLEXMEASURES_JOB_CACHE_TTL, one hour by default), so it is gone exactly when a run outlives its claim lease or Redis restarts — which is the failure mode durable runs exist to survive. It also hashes the call arguments, so anything that varies between attempts breaks it:AutomationRun.parametersis a snapshot of the automation's raw parameters (seeclaim_due_automation_run), so a schedule automation that omitsstartresolves a fresh one per attempt viaprepare_schedule_trigger_messageand hashes differently. And per #2404 the cache can hand back a wedged job rather than a fresh one.What needs to happen
create_sequential_scheduling_jobcurrently forwards a singlejob_idto everycreate_scheduling_jobcall in its loop (flexmeasures/data/services/scheduling.py:514). That is harmless today because every caller passesNone; the moment a real ID is passed, all device jobs collide on it. This needs a per-device ID before anything else can be built on top.cb_done_sequential_scheduling_jobjob is built with a bareJob.createand has nojob_idparameter at all.device-<sensor id>andwrap-up, andqueue="scheduling", so the existingensure_automation_run_job_intents/mark_automation_job_queued/reconcile_automation_job_intentmachinery applies unchanged.depends_onchain on a resumed dispatch against the jobs that already exist, instead of re-creating the chain from scratch.record_automation_job_started/_succeeded/_failed, so a schedule run'sexecution_statenever leavespendingand the Automations page shows it that way indefinitely. This is arguably the more visible half of the gap, and is worth doing even if 1–4 are deferred.test_only_a_forecast_run_is_dispatched_a_second_time), and updatedocumentation/features/automations.rstand both changelogs, which currently spell the restriction out.Notes
fallback_scheduler_class, sotrigger_optional_fallbacknever fires in core; the hook survives for plugins that set it. The only thing to watch is that a fallback job, which inherits the original'striggermeta and is created withforce_new_job_creation=True, must not be handed the deterministic ID of the job it replaces.flexmeasures add scheduleand any plugin scheduler all go through, so they deserve their own tests independent of automations.Acceptance criteria
pending.