fix(cron): deduplicate cross-profile jobs in GET /api/cron/jobs
profile=all aggregated every profile's list without deduplication, so a job copied into a second profile's cron/jobs.json during profile creation appeared twice — inflating the desktop sidebar count and rendering duplicate rows. Collect all jobs first, then resolve duplicates by id with default-profile priority, instead of keeping whichever copy the profile loop happened to append first. cron.jobs._normalize_job_record() fills a missing id with the literal sentinel string "unknown", which is truthy — treat it like no id so two genuinely different id-less legacy records from different profiles are never collapsed into one. Fixes #51721 Salvaged from #69132 by @ygd58 (kept its dedup semantics and regression tests, ported onto the web_routers/cron.py seam after the web_server refactor). Co-authored-by: ygd58 <buraysandro9@gmail.com>
This commit is contained in:
@@ -107,16 +107,42 @@ def _list_cron_jobs_sync(profile: str = "all"):
|
||||
if requested.lower() != "all":
|
||||
return _call_cron_for_profile(requested, "list_jobs", True)
|
||||
|
||||
jobs: List[Dict[str, Any]] = []
|
||||
# Aggregating across profiles can surface the SAME job id more than once —
|
||||
# e.g. a job copied into a second profile's cron/jobs.json during profile
|
||||
# creation. Deduplicate by id, deterministically preferring the default
|
||||
# profile's copy over per-iteration order (#51721): collect all jobs first,
|
||||
# then resolve duplicates by id with default-profile priority, rather than
|
||||
# keeping whichever copy happened to be seen first during the profile loop.
|
||||
all_jobs: List[Dict[str, Any]] = []
|
||||
for item in _cron_profile_dicts():
|
||||
name = str(item.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
|
||||
all_jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
|
||||
except Exception:
|
||||
_log.exception("Failed to list cron jobs for profile %s", name)
|
||||
return jobs
|
||||
|
||||
by_id: Dict[str, Dict[str, Any]] = {}
|
||||
unkeyed: List[Dict[str, Any]] = []
|
||||
for job in all_jobs:
|
||||
if not isinstance(job, dict):
|
||||
continue
|
||||
jid = job.get("id") or job.get("job_id")
|
||||
# cron.jobs._normalize_job_record() fills a missing id with the literal
|
||||
# sentinel string "unknown" — which is truthy, so a plain `if not jid`
|
||||
# check would NOT catch it and two genuinely different id-less jobs from
|
||||
# different profiles would collapse into one under this shared sentinel
|
||||
# key. Treat the sentinel the same as no id: never deduplicated.
|
||||
if not jid or jid == "unknown":
|
||||
unkeyed.append(job)
|
||||
continue
|
||||
existing = by_id.get(jid)
|
||||
if existing is None or (
|
||||
job.get("is_default_profile") and not existing.get("is_default_profile")
|
||||
):
|
||||
by_id[jid] = job
|
||||
return list(by_id.values()) + unkeyed
|
||||
|
||||
|
||||
def _get_cron_job_sync(job_id: str, profile: Optional[str] = None):
|
||||
|
||||
Reference in New Issue
Block a user