Skip to content

fix: Count automation jobs in a fixed number of Redis round trips - #2647

Open
BelhsanHmida wants to merge 4 commits into
mainfrom
perf/automations-job-stats-redis
Open

BelhsanHmida wants to merge 4 commits into
mainfrom
perf/automations-job-stats-redis

Conversation

@BelhsanHmida

@BelhsanHmida BelhsanHmida commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Automations page refreshes its job stats every minute. Counting one automation's jobs reads the Redis index of every sensor it covers — a schedule automation has one per sensor in its asset's subtree — and JobMap.get reads one index per call, with its own SMEMBERS. So a page covering 22 sensors pays 22 round trips before it fetches a single job.

Changes

  • JobMap.get_many reads any number of indexes in a fixed number of round trips: one ping, one pipeline for the job IDs, one Job.fetch_many for the jobs, and one pipeline to remove IDs whose job has expired. JobMap.get delegates to it, so a single-entry read behaves exactly as before.
  • The automation job stats ask for every index at once, and read each job's status with get_status(refresh=False), since the job was just fetched with its status rather than needing another read per job.
  • Tests pin the round trips — one direct command plus two or three pipelines, however many sensors and jobs — and both fail against main's implementation.

What #2619 already did, and what is left

This branch was written against JobCache, before #2619 renamed it to JobMap and gave its get a Job.fetch_many. That delivered the larger half of this work: the round trip per job is already gone from main. What remains, and what this PR now does, is the round trip per sensor, which get_many collapses into one pipeline.

⚠️ The measurements below predate #2619, so they describe the gap between this branch and a main that no longer exists. They are kept for the shape of the problem rather than as a claim about today; the honest summary is that an automation covering n sensors with m jobs went from n + 2m round trips to n + 2, and now goes from n + 2 to 3.

Redis round trip GET /assets/<id>/automations GET /assets/<id>/automations/<id>
0.23 ms (local) 1132 → 354 ms 864 → 238 ms
0.59 ms 2022 → 342 ms 1481 → 257 ms
2.6 ms 5651 → 315 ms 3903 → 307 ms

Worth re-measuring against current main before anyone quotes a figure from this PR.

Tests

The two unit tests that mocked a Redis connection with spec_set=["sadd", "smembers", "srem", "ping"] could not survive a method that uses pipeline, and mocking the spec would only have asserted that the mock was called. They are replaced by two checks against RQCompatibleFakeStrictRedis, which run the real commands:

  • test_get_drops_expired_jobs — an expired job is left out and its ID removed from the index. This passes on main too, deliberately: it is the control that says the port did not lose behaviour.
  • test_get_many_takes_a_fixed_number_of_round_trips — three entries, a job indexed under two of them, and an expired ID; one ping and three pipelines, with the shared job returned under both entries. Fails on main.
  • test_automation_stats_take_a_fixed_number_of_redis_round_trips — the same, through the automations service, over a schedule automation covering three sensors. Fails on main.

Not in this PR

  • The remaining time is parsing the full job records, which would mean reading fields around rq's Job.
  • The sensor page has a separate slow-down that does not involve Redis. To list the automations feeding a sensor, get_automations_feeding_sensor rebuilds the data generator or schedule trigger message of every automation on the sensor's asset and its ancestors, on every page load. That cost 60–90 ms per sensor page on a copy of the EMS database with 14–17 automations above the sensor, measured with plugins off, so the InsideOut reporters were skipped and the real cost is higher. Possible follow-ups:
    • Store each automation's output sensor IDs when it is created or edited, and read those. This removes the cost, but needs a migration and a way to keep the stored IDs in sync.
    • Load the feeding automations section with its own request after the page renders.
    • Rule out forecast and report automations cheaply first, since they name their output sensors in their parameters.

Changelog

No entry of its own: the automations page is new in this release, so what it costs to load belongs with the entry that introduces it, where this PR is now named alongside #2294, #2563 and #2591.

Closes #2646

JobCache.get pinged Redis, read the entry's job IDs with SMEMBERS, and then
fetched each job with its own Job.fetch. A page that reads many entries, such
as the jobs of every sensor in an asset's subtree, so paid a round trip per
entry and another per job, which adds up quickly once Redis is not on the
same machine.

JobCache.get_many reads any number of entries in a fixed number of round
trips: one ping, one pipeline for the job IDs of all entries, one
Job.fetch_many for all jobs (fetching a job listed under several entries
once), and one pipeline to remove the IDs of jobs that expired. JobCache.get
is now a single-entry call to it, so its behaviour is unchanged.

The two mock-based tests of get stubbed Job.fetch, so they are replaced by
tests on fakeredis with real jobs: one checks that expired jobs are left out
and removed from the cache, and one pins the number of direct commands and
pipelines that get_many sends.

Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
The Automations page shows, per automation, how many of its jobs are queued,
finished or failed, and refreshes this every minute. Counting them read each
job cache entry separately, and then asked Redis for every job's status
again with get_status(), although the job had just been fetched with its
status. With 1000 jobs that is over 2000 round trips per refresh, per open
tab.

The counts now read all entries with JobCache.get_many and take each job's
status from the fetched job with get_status(refresh=False). On a test site
with 22 sensors and 1000 jobs, this brings the automations list endpoint
from 1.1 s to 0.35 s with Redis on the same machine, and from 2.0 s to
0.34 s at 0.6 ms per round trip, with identical responses.

A test counts the Redis commands and pipelines sent while counting the jobs
of a schedule automation spread over several sensors, and checks the counts
per status. On the previous code it sees 13 direct commands instead of 1.

Signed-off-by: Mohamed Belhsan Hmida <mohamedbelhsanhmida@gmail.com>
@BelhsanHmida BelhsanHmida self-assigned this Oct 2, 2026
@BelhsanHmida BelhsanHmida added bug Something isn't working Automations labels Oct 2, 2026

@Flix6x Flix6x left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the diff and checked the three things that could have made this subtly wrong. All three hold, and I verified rather than assumed:

  • Job.fetch_many is aligned with its input. On rq 2.10.0 it returns a list with None in the place of each missing job (rq/job.py:700-730), so zipping it against unique_job_ids cannot shift jobs onto the wrong ids. Had it dropped the missing ones instead, every id after the first expired job would have mapped to the wrong job — worth stating explicitly, because the code reads the same either way.
  • cache_refs is hashable. _job_cache_refs returns set[tuple[int, str, str]], so dict.fromkeys(entries) is safe. A caller passing lists would raise, which is fine for a private-ish helper, and get() builds its own tuple.
  • get_status(refresh=False) has something to read. fetch_many calls restore(), which sets _status, so the status comes from the hash that was just read. It also makes a page's counts one consistent snapshot rather than a status re-read per job, which I think is a small improvement in its own right.

I ran both new tests on the branch (26 passed) and then against main's job_cache.py and automations.py: both fail, so they bind. The round-trip assertions are the right shape for a performance fix — they pin the thing that regresses, not a timing.

One thing before merge: there is no changelog entry. The diff is four files, none of them documentation/changelog.rst, and the numbers in the description are squarely user-facing — an automations page going from 1.1 s to 0.35 s locally, and from 5.7 s to 0.3 s on a 2.6 ms link, is exactly what a reader of the changelog wants to know. Happy to push one if you would rather not.

Two notes, neither blocking:

  • The round-trip counts encode that fetch_many uses exactly one pipeline, which is rq's implementation rather than its contract. If a future rq splits or chunks that, the test fails for a reason unrelated to FlexMeasures. Fine by me — a perf regression test has to pin something — but worth knowing when it next goes red.
  • get_many([]) still pings. Harmless, though _count_automation_jobs with no cache refs now costs a round trip where it previously cost none.

On the overlap you flagged: services/sensors.py:855 and :867 still call get() per entity, and since #2619 also touches JobCache.get, whichever lands second will want a look rather than a blind conflict resolution. Worth deciding the order deliberately.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129WrXeJ5gia2pctFH93BqC

…tomations page

The page is new in this release, so what it costs to load belongs with the entry that introduces it,
rather than as a line of its own about a slowdown nobody has had to live with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WrXeJ5gia2pctFH93BqC
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x

Flix6x commented Oct 3, 2026

Copy link
Copy Markdown
Member

Changelog done in bb53d54, and not as an entry of its own: the automations page is new in this release, so what it costs to load belongs with the entry that introduces the page rather than reading as a fix for a slowdown nobody has had to live with. This PR is now named alongside #2294 and #2563 there.

Heads-up on an overlap that will bite whoever merges second: #2591 edits that same line, to add Copy to the list of row actions. The conflict is one line and resolves by keeping both additions — the extra PR link and the Copy wording.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129WrXeJ5gia2pctFH93BqC

#2619 renamed JobCache to JobMap and gave its `get` a `Job.fetch_many`, so the round trip per job this branch removed is already gone from main.
What is left is the round trip per entry: `get` reads one index with its own `smembers`, so a page covering many sensors still pays one per sensor.

`get_many` moves to `JobMap`, where it reads any number of indexes in one pipeline, fetches the jobs in another, and removes expired IDs in a third.
`get` delegates to it, and the automation job stats ask for every index at once.

The two unit tests that mocked a connection without `pipeline` are replaced by checks against a fake Redis, which exercise the real commands:
one for expired IDs being dropped from their index, one pinning the round trips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WrXeJ5gia2pctFH93BqC
Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x Flix6x added this to the 1.1.0 milestone Oct 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Automations bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automations page makes two Redis round trips per job, every minute

2 participants