Feat/2393 durable automation runs - #2457
Conversation
An automation occurrence currently leaves no trace of its own: the only durable marker is the automation's scheduling cursor, which says an occurrence was attempted, not what happened to it. Record each occurrence a runner picks up as an AutomationRun, with the attempts made on it (AutomationRunAttempt) and the jobs it intends to create (AutomationRunJob). Dispatch progress and worker execution outcome are tracked separately, because 'queued everything' and 'the jobs succeeded' are different questions an operator needs answered. The database enforces one run per automation, occurrence and schedule revision, and one job intent per run and logical job key. The new schedule revision on Automation keeps runs of an edited or reactivated schedule apart from the runs of the schedule it replaced, even at the same scheduled UTC time. All run timestamps are validated to be timezone-aware and stored as UTC. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
…partial dispatch The runner used to advance and commit the scheduling cursor before queueing anything, guarded only by a Redis key with a two-minute TTL. A failure before the first enqueue therefore lost the occurrence for good, while retrying a partial enqueue could have duplicated work. Claim each due occurrence into an AutomationRun instead, write down the plan for the run before the first enqueue, and give every intended job a logical key and, from it, a deterministic RQ job ID. A retry replays that stored plan: jobs whose IDs are already in Redis are recognised and left alone, and only the missing ones are queued. Because the plan holds the parameters and timings the occurrence was planned with, a retry hours later still dispatches the occurrence as originally intended, even if the automation has been edited since. Ownership is a database lease, not a Redis key. An occurrence is only picked up by another runner once the lease of the runner holding it has expired, which is how a runner that died mid-queueing hands its work over. A runner that fails releases its own lease, so its run is retryable at once. Dispatch is finished only when it is marked complete, so a crash between the last enqueue and that mark is finalized by the next runner rather than left hanging. Forecast cycle and wrap-up jobs now carry their run identity and report their own start, success and failure back to it, so the execution outcome outlives the Redis jobs. Queueing the jobs of a pipeline run moved out of the already long run() into its own methods, which also removes the duplicate queueing path. Editing an automation's cron string or timezone, or reactivating it, counts up its schedule revision, so runs of the old and new schedule stay distinct. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
Job counts came from Redis alone, so once its jobs expired there was no way to tell whether an occurrence had failed before queueing anything, queued only part of its work, or queued everything and then failed while computing. Add a run_stats object to the automation detail response, summarizing the automation's durable runs and describing the recent ones: their occurrence, dispatch and execution state, attempt count, intended and queued job counts, timestamps, last error, latest attempt, and the jobs they created. Automation responses also expose schedule_revision. The automation details panel shows the latest run alongside the recent Redis jobs. Both additions are additive: no existing field changed. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
The forecasting docs and the CLI change log stated that a failed or partially completed queueing attempt is never retried, which no longer holds. Describe how an occurrence is claimed, planned, dispatched and retried instead, and what the dispatch and execution states mean, along with the changelog entries for the new run status in the API and the UI. Those two stale CLI change log lines are corrected rather than left standing: they describe an unreleased release, so leaving them would ship a change log that contradicts itself. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
The tuple of statuses that count a job intent as dispatched was written out in the model and, unused, in the service. Define it once next to the model that asks the question, and drop the copy nobody read. Also apply black to the migration and drop an unused import from the new test module. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
…he transaction Running an automation against a real database and a real Redis turned up two ways the durable execution record lied about what happened. A cycle job that failed on the database itself left the session in an aborted transaction, so recording its failure was refused and the job stayed 'running' forever. Roll back before recording, the way dispatch failures already do; the job's uncommitted work is lost either way, since it is failing. A later job succeeding also reset the run to 'running', burying an earlier failure: the wrap-up job succeeds whatever became of the cycle jobs it reports on. Derive the run's execution state from all of its jobs instead, so a failed job keeps the run failed. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
The entry linked the issue, because no PR existed when it was written. Point it at PR #2457 instead, which is what the changelog convention asks for. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
Documentation build overview
52 files changed ·
|
…s test test_post_sensor_data_twice registers a 'handle_error' listener on the Engine class, which is global and process-wide, and never removes it. Every database error raised by any later test therefore ran its assertion that the error is an IntegrityError, so a test which provokes a different error fails inside SQLAlchemy rather than where it looks. Remove the listener in a finally block, so it only covers the posts it is about. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
…omation-runs Main gained on-demand automation runs (PR 2460), which touches the same CLI, API and UI surface as the durable runs work. Conflicts were in the docs only, and all three were additive: both changelog entries and both documentation sections describe separate features and were kept side by side. The one exception is the CLI change log entry for 'jobs run-automations', where this branch's wording replaces main's: main still said the runner makes at most one queueing attempt per automation per minute, which durable claiming has made untrue. Git merged the code cleanly but dropped the 'run_automation' import from flexmeasures/cli/jobs.py, because this branch rewrote that import block while main added a caller for it. Restored, which is what the three on-demand CLI tests caught. An on-demand run still queues its jobs without a durable run record, exactly as PR 2460 built it; giving those runs records too is left as a follow-up. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
…omation-runs Three conflicts, two of them changelog entries which were simply additive. The real one is in the train-predict pipeline: this branch extracted the body of 'run()' behind '_queue_cycle_jobs', while main edited that body in place. Kept this branch's call and carried main's own change across into the method it moved to, which is that '_persist_data_source_id' now reads the ID after committing rather than flushing first. Main's other changes to that file, the training-window rework of PR 2482, the dry-run parameter of PR 2483 and the cycle logging, are all outside the extracted methods and merged cleanly. The OpenAPI spec auto-merged; regenerating it produced no further change. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
…in's Merging main left the migration graph forked: main's 'drop obsolete tables' revision and this branch's own both named 84f268f5153c as their parent, so alembic saw two heads and 'flexmeasures db upgrade' refused to run, which is what failed the Docker image build. Migration files sit in separate files and never conflict textually, so the merge looked clean while the graph did not. Point this branch's revision at main's instead, making the chain linear again. The two migrations do not interact: main's drops nine obsolete pre-GenericAsset tables, this one only adds the automation run tables and a column on 'automation'. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
There was a problem hiding this comment.
🟡 Changes recommended
record_automation_job_started can overwrite a previously recorded failed run back to “running”, and several new docstrings violate the repo’s line-break-after-punctuation rule.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces durable, database-backed automation run records to make scheduled automation dispatch idempotent and safely retryable, while exposing run/attempt/job outcome details through the API, CLI output, and UI.
Changes:
- Add durable run/attempt/job tables plus claiming/lease logic to support safe retries after pre-enqueue and partial-enqueue failures.
- Wire durable run identity through queued forecasting jobs and record durable execution outcomes (started/succeeded/failed).
- Expose
schedule_revisionandrun_statsvia API/OpenAPI, surface the same in the asset Automations UI, and document the new behavior.
File summaries
| File | Description |
|---|---|
| flexmeasures/ui/templates/assets/asset_automations.html | Display schedule_revision and durable run_stats in the automation details modal |
| flexmeasures/ui/static/openapi-specs.json | Update OpenAPI examples/description for schedule_revision and run_stats |
| flexmeasures/data/tests/test_automation_scheduling_fresh_db.py | Update scheduling tests to claim durable runs instead of legacy claims |
| flexmeasures/data/tests/test_automation_runs_fresh_db.py | Add regression coverage for durable run claiming, retry semantics, and durable outcome recording |
| flexmeasures/data/services/forecasting.py | Record durable job failure info from forecasting exception handler when run metadata is present |
| flexmeasures/data/services/automations.py | Core implementation of durable run claiming, job intents, dispatch tracking, reconciliation, and serialization/stats |
| flexmeasures/data/schemas/automations.py | Add schedule_revision to automation schema output |
| flexmeasures/data/models/forecasting/pipelines/train_predict.py | Plan deterministic job IDs for automation runs and record durable job outcomes |
| flexmeasures/data/models/data_sources.py | Extend job trigger metadata to include automation_run_id |
| flexmeasures/data/models/automations.py | Add models for AutomationRun, AutomationRunAttempt, AutomationRunJob, and Automation.schedule_revision |
| flexmeasures/data/migrations/versions/f3d8e2c9a741_add_durable_automation_runs.py | Alembic migration creating new durable run tables and adding schedule_revision |
| flexmeasures/cli/tests/test_automations.py | Update runner CLI test to expect durable run/attempt reporting |
| flexmeasures/cli/jobs.py | Replace Redis guard with durable run dispatch + retry behavior and improved CLI messaging |
| flexmeasures/cli/data_edit.py | Increment schedule_revision on schedule rebase (cursor reset) |
| flexmeasures/api/v3_0/tests/test_sensor_data.py | Ensure DB error listener is always removed to avoid leaking into later tests |
| flexmeasures/api/v3_0/tests/test_automations_api.py | Add API assertions/examples for schedule_revision and run_stats outcome distinctions |
| flexmeasures/api/v3_0/assets.py | Include durable run_stats in automation detail API responses |
| documentation/features/automations.rst | Document durable runs, retries, leases, and dispatch vs. execution state concepts |
| documentation/cli/change_log.rst | Document durable run recording and retry semantics for the automation runner CLI |
| documentation/changelog.rst | Add a main changelog entry describing durable automation run tracking |
| documentation/api/change_log.rst | Document new API fields (schedule_revision, run_stats) and their semantics |
Review details
Suppressed comments (6)
flexmeasures/data/services/automations.py:226
- Docstring line wrap breaks mid-phrase (line ends with "to"), violating the repository rule to only break lines after punctuation.
A run is only up for grabs once no other runner holds a live claim on it, because the dispatch state turns to
'partially_queued' while the owning runner is still queueing the rest of its jobs.
flexmeasures/data/services/automations.py:461
- Docstring wraps mid-phrase (line ends with "where" / "own"), violating the repo-wide rule to only break lines after punctuation.
The job may well have failed on the database itself, which leaves the session in an aborted transaction where
every further statement is refused. Roll back first, so that the failure is still recorded. The job's own
uncommitted work is lost either way, since it is failing.
flexmeasures/data/services/automations.py:418
- Docstring wraps mid-phrase (line ends with "the"), violating the repo-wide rule to only break lines after punctuation.
A failed job keeps the whole run failed: a later job succeeding, as the wrap-up job does whatever became of the
cycle jobs it reports on, must not put the run back to 'running' and bury the failure.
flexmeasures/data/tests/test_automation_runs_fresh_db.py:330
- Docstring wraps mid-phrase (line ends with "durable"), violating the repo-wide rule to only break lines after punctuation.
All jobs are already in Redis, so a later runner must adopt the abandoned claim, reconcile the durable
intents against Redis, and complete the dispatch without queueing anything again.
flexmeasures/data/tests/test_automation_runs_fresh_db.py:382
- Docstring wraps mid-phrase (line ends with "run"), violating the repo-wide rule to only break lines after punctuation.
The occurrence stays claimed until the lease runs out, after which a later runner adopts the same durable run
instead of creating a second one.
flexmeasures/data/tests/test_automation_runs_fresh_db.py:676
- Docstring wraps mid-phrase (line ends with "as"), violating the repo-wide rule to only break lines after punctuation.
The wrap-up job succeeds whatever became of the cycle jobs it reports on, so it must not report the run as
merely still running and bury the failure an operator needs to see.
- Files reviewed: 21/21 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com>
…whole history An automation keeps one run record per scheduled run, so its history grows without bound, while the status summary only ever shows counts and the ten most recent runs. It nonetheless loaded every run to produce that, and each run drags its 'parameters' and 'plan' JSONB along, so an automation running by the minute would have the panel read a year of job payloads to render. Count per dispatch and execution state in the database instead, read only the recent runs the summary describes, and eager-load their attempts and jobs, which were costing a query each on top. The regression test asserts what the shape of the queries must be, rather than how long they take: no query may read run rows without a limit on how many. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
Main gained schedule automations (#2293), which the durable-run work did not know about. The runner now names the automation's type in what it reports, and a run dispatched from its own record schedules the parameters it was planned with, naming itself on the job it queues. Only a forecast run is dispatched a second time: its jobs carry IDs derived from the run, so a retry recognizes the ones it already queued, whereas a schedule run's jobs get a fresh ID on every dispatch, so retrying one would duplicate its schedules. A schedule run is recorded, claimed and reported like any other, but left where it failed. The durable-runs migration now chains after main's automation-type merge revision, leaving a single head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WS6V8nZyRzMxsvqGnNpUTk Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Merged Conflicts
Decisions
Tests: 1149 passed, 1 xfailed across
🤖 Generated with Claude Code |
Main carved out a v1.0.1 changelog section, moving the #2507 and #2502 entries out of v1.1.0. Took main's copy of the file; this PR's entry is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WS6V8nZyRzMxsvqGnNpUTk Signed-off-by: F.N. Claessen <claessen@seita.nl>
| This is what makes a retry safe. | ||
| A run which failed before queueing anything is dispatched again in full. | ||
| A run which queued only some of its jobs resumes from the same plan, recognizes the jobs already in Redis by their IDs, and queues only the ones still missing, so a retry never duplicates work, and never silently drops it either. | ||
| Because the plan is stored, a retry hours later still uses the parameters and timings the run was originally planned with, even if the automation has been edited since. |
There was a problem hiding this comment.
This half-overclaims, and the half that is wrong has a visible consequence.
The parameters part is true. claim_due_automation_run stores parameters=dict(automation.parameters or {}), so an edit after the run was claimed does not reach it.
The timings part is not. Only timings that were explicitly given are in those parameters. One that defaults to the run time was never stored, so it is re-resolved on every attempt: TrainPredictPipelineParametersSchema sets predict_start = floored_now when start is absent (flexmeasures/data/schemas/forecasting/pipeline.py:658), and belief_time defaults to now alongside it. ensure_automation_run_job_intents reuses the stored intents, but the job specs handed to it are recomputed each attempt, so identity is preserved across a retry while the resolved window is not.
The consequence is that one run's jobs can disagree about what they are forecasting.
Repro
Drop this in as flexmeasures/data/tests/test_repro_timings.py and run it. A forecast automation with no explicit --start; attempt 1 at 01:00; one job dropped from Redis to simulate a dispatch that queued only part of its work; attempt 2 at 03:00.
from datetime import datetime, timezone
from rq.job import Job
from sqlalchemy import select
from flexmeasures.cli.tests.utils import to_flags
from flexmeasures.data.models.automations import Automation
def test_a_resumed_run_reresolves_a_start_it_never_stored(
app, fresh_db, setup_fresh_test_forecast_data, freeze_server_now, clean_redis
):
from flexmeasures.cli.data_add import add_automation
from flexmeasures.data.services.automations import (
dispatch_automation_run,
get_dispatchable_automation_runs,
)
sensor = setup_fresh_test_forecast_data["solar-sensor"]
# A forecast automation with no explicit --start: its start defaults to the run time.
freeze_server_now(datetime(2026, 8, 5, 0, 58, tzinfo=timezone.utc))
runner = app.test_cli_runner()
result = runner.invoke(
add_automation,
to_flags(
{
"asset": sensor.generic_asset_id,
"name": "No explicit start",
"cron": "0 1 * * *",
"timezone": "UTC",
"sensor": sensor.id,
"duration": "PT2H",
"forecast-frequency": "PT1H",
"max-forecast-horizon": "PT2H",
"retrain-frequency": "PT1H",
}
),
)
assert result.exit_code == 0, result.output
automation = fresh_db.session.scalars(select(Automation)).one()
# Attempt 1, at 01:00.
freeze_server_now(datetime(2026, 8, 5, 1, 0, tzinfo=timezone.utc))
claimed = get_dispatchable_automation_runs(owner="runner:1")
assert len(claimed) == 1
run = claimed[0].run
dispatch_automation_run(claimed[0])
fresh_db.session.refresh(run)
print("\nrun.parameters (snapshot):", dict(run.parameters))
connection = app.queues["forecasting"].connection
starts = lambda: {
i.logical_job_key: Job.fetch(i.rq_job_id, connection=connection).meta.get("start")
for i in run.job_intents
}
first = starts()
print("attempt 1 job starts:", first)
# Simulate an attempt that queued only part of its jobs: drop one job from Redis,
# put its intent back to pending, and hand the run back as resumable.
victim = next(i for i in run.job_intents if i.logical_job_key == "cycle-002")
Job.fetch(victim.rq_job_id, connection=connection).delete()
victim.status = "pending"
victim.enqueued_at = None
run.dispatch_state = "partially_queued"
run.dispatch_completed_at = None
run.claim_owner = None
run.claim_expires_at = None
fresh_db.session.commit()
# Attempt 2, two hours later.
freeze_server_now(datetime(2026, 8, 5, 3, 0, tzinfo=timezone.utc))
resumed = [c for c in get_dispatchable_automation_runs(owner="runner:2") if c.run.id == run.id]
assert resumed, "the run was not picked up again"
dispatch_automation_run(resumed[0])
fresh_db.session.refresh(run)
print("attempt 2 job starts:", starts())Output:
run.parameters (snapshot): {'sensor': '2', 'duration': 'PT2H', 'forecast-frequency': 'PT1H', 'max-forecast-horizon': 'PT2H'}
attempt 1 job starts: {'cycle-001': '2026-08-05T01:00:00+00:00', 'cycle-002': '2026-08-05T01:00:00+00:00', 'wrap-up': '2026-08-05T01:00:00+00:00'}
attempt 2 job starts: {'cycle-001': '2026-08-05T01:00:00+00:00', 'cycle-002': '2026-08-05T03:00:00+00:00', 'wrap-up': '2026-08-05T01:00:00+00:00'}
No start in the stored parameters, because none was ever given. After the resume, cycle-001 and cycle-002 of the same run forecast from different starts, and the wrap-up job reports on the 01:00 window.
Suggestion
The smallest fix is to say what is actually guaranteed, e.g.:
Because the plan is stored, a retry hours later still uses the parameters the run was planned with, even if the automation has been edited since. Timings the automation left to the run time are not part of those parameters, so they are resolved afresh on each attempt: a resumed run's jobs can therefore cover a later window than the ones its first attempt queued.
The larger fix is to resolve the run's timings once, at claim time, and store them on AutomationRun.parameters — which would make the sentence true as written, and give a resumed run one coherent window. That is a behaviour change, so it may belong in its own PR; the docs edit is enough to make this one accurate.
Same question applies to AutomationRun.parameters for schedule automations, where prepare_schedule_trigger_message defaults start to server_now(). It does not bite today, since schedule runs are not retried, but it would as soon as they are (#2510).
There was a problem hiding this comment.
Checked it you're right. Ran your test locally and got the same split: cycle-002 at 03:00 while cycle-001 and the wrap-up stayed at 01:00. pushed docs update commits
I have the behaviour fix working locally too: pin the resolved start onto the run on first dispatch, so later attempts read it back. Start only pinning end as well trips the "duration with either start or end, but not both" check. It settles belief_time too, since the schema derives that from the start whenever one is given.
Keeping it out of this PR as you suggested. i'll make a follow-up pr
The runs section claimed a retry reuses the parameters and timings the run was planned with. The parameters part holds, since the run stores its own copy. The timings part does not: only timings that were explicitly given are in those parameters, so one an automation left to the run time is resolved afresh on every attempt, and a resumed run's remaining jobs can cover a later window than the ones its first attempt queued. State what is actually guaranteed. Pinning the resolved start onto the run, so that the original sentence would hold, is a behaviour change and belongs in its own pull request. Reported by Felix in review of PR 2457. Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
Signed-off-by: Mohamed Belhsan Hmida <149331360+BelhsanHmida@users.noreply.github.com>
Description
automation_run(one row per scheduled run, unique on(automation_id, scheduled_at, schedule_revision)),automation_run_job(the jobs a run intends to create, with deterministic RQ job IDs) andautomation_run_attempt(one row per dispatch attempt).Automation.schedule_revision, counted up wheneverflexmeasures edit automationrebases the cursor, so runs of the old and the new schedule stay distinct.SET NXguard to the database, keeping the same cursor compare-and-swap. Removesclaim_due_automation()and the Redis guard.pending,claimed,partially_queued,queued,failed) separately from execution state (pending,running,succeeded,failed,canceled), the latter derived from all job intents so a late success cannot hide an earlier failure.schedule_revisionand arun_statsobject to the automations API, and the same run status to the asset's Automations page. Backward compatible: no existing field changed.flexmeasures jobs run-automationsreport the run and attempt it works on, and retry runs whose queueing did not finish.documentation/changelog.rstLook & Feel
flexmeasures jobs run-automations, on a run whose previous attempt queued only part of its jobs:and when an attempt fails, naming the run and the attempt rather than just the automation:
[GET] /assets/(id)/automations/(automation_id)gainsrun_stats:The automation details modal on the asset's Automations page shows the schedule revision and a Durable runs block with the same totals, dispatch and execution breakdown, and latest run — next to the existing Recently created jobs counts, which come from Redis and disappear as jobs expire, whereas the run records do not.
How to test
Automated coverage:
Related Items
Closes #2393.
Sign-off