Reschedule on project and same-ID task content changes - #854
Conversation
Scheduler._update_schedule() diffed task IDs only, so a project priority change or a same-ID task content change (e.g. priority) never triggered a reschedule even once the archive re-polled. Also fixes an assignment-order bug where self._projects was overwritten before it could ever be diffed against. - Compare downloaded projects against the previous list via model_dump(), keyed by code, mirroring PortalTaskArchive._update(). - Add Scheduler._changed_task_ids() for same-ID content diffs. A content change on the currently-running task always forces a reschedule (unlike a removed one, which is ending anyway); a content change on a task not in the current schedule is skipped, reusing the existing schedule-membership guard.
thusser
left a comment
There was a problem hiding this comment.
Review (verified against the PR head a374458d)
Independently re-ran the claimed checks in the PR worktree:
| Check | Result |
|---|---|
pytest tests/modules/robotic/test_scheduler.py |
60 passed (50 baseline + 10 new) |
| Full non-integration suite | 1762 passed, 28 failed — all failures pre-existing (the same files fail on develop: cli/test_pyobsd.py, mixins/test_fitsheader.py, camera/test_basevideo.py, telescope/test_dummysolartelescope.py; environment/flaky, unrelated to this PR) |
ruff check + black --check on changed files |
clean |
The scheduler logic itself is sound: the assignment-order fix, the model_dump()-keyed-by-code project diff mirroring PortalTaskArchive._update(), _changed_task_ids() (private attrs excluded from the dump by construction), the running-task asymmetry, and the extended downgrade guards all check out. The new tests assert the interesting transitions, including get_schedule() not being awaited on the running-task path.
1. BLOCKER — core Project will reject the portal's new updated_at; the two PRs are not independently landable
pyobs-portal #134 adds Project.updated_at, and ProjectSerializer uses fields="__all__", so /api/projects/ will start emitting updated_at. Core's Project (pyobs/robotic/task.py:152) extends pyobs.utils.serialization.BaseModel (extra="forbid") and has no such field, so PortalTaskArchive._get_projects() (pyobs/robotic/storage/portal/taskarchive.py:117) will raise ValidationError: Extra inputs are not permitted on every poll once the portal half lands. Verified directly:
Project.model_validate({..., "updated_at": "2026-09-01T10:20:30Z"})
→ ValidationError [type=extra_forbidden] (Task.model_validate with the field: OK)
Consequences: _update() dies before _get_tasks(), _last_marker never advances, and the poll loop retries forever with one throttled ERROR per minute — task and project updates silently stop flowing to the scheduler. Since this PR's whole purpose is to react to those updates, the feature is inert end-to-end until the round-trip field lands.
The precedent is already in this repo: e550423e ("fix: accept updated_at field from robotic-backend (#84)") added updated_at: str | None = None to Task together with a regression test (test_task_get_tasks_from_portal_accepts_updated_at, tests/robotic/storage/portal/test_portal_archives.py:154). The same one-liner + test is needed for Project — best placed in this PR (or an immediate companion):
class Project(BaseModel):
code: str
name: str = ""
priority: float | None = Field(ge=0.0, le=9999.0, default=1.0)
users: list[str] = Field(default_factory=list)
public: bool = False
updated_at: str | None = None # round-trip: portal emits it (pyobs-portal#134); extra="forbid"Both plans' "independently landable" claims should be amended accordingly.
2. LOW — model_dump() also compares Task.updated_at; the docstring overstates "only real content differences"
updated_at is a declared field, so it's part of the dump — consistent with PortalTaskArchive._update() and the marker design (any save moves the marker, the archive fires, the scheduler sees the timestamp differ), but it means a no-op save (e.g. a DRF PATCH with unchanged data still calls Task.save()) counts as "changed" and forces a full reschedule when the task is scheduled. If only semantic changes should trigger, exclude updated_at from the comparison; otherwise the _changed_task_ids docstring should say "any field difference, including updated_at". The same applies to projects_changed once finding 1's field lands.
3. LOW — the private-attr regression test is narrower than the plan asked for
The plan wanted a task run through the real self._scheduler.schedule(...) (or resolve_target()/can_run()) and diffed against a fresh copy; the implementation pins only _cant_run_reason. The mechanism test covers the core risk, but a single end-to-end scheduling round-trip would also guard _resolved_target/_running_script and any future public-field mutation — cheap insurance given the livelock history.
Nits
- PR body says "51 pre-existing tests"; the file has 50 on
develop(60 on this branch). - No test for a mixed poll (removed + changed together, e.g. running task removed while an off-schedule task changed) or for project removal — both handled by the code but untested.
- Add Project.updated_at (str | None = None) for round-trip compatibility with pyobs-portal#134's ProjectSerializer, which now emits it on every /api/projects/ response. Without this, Project's extra="forbid" model rejects the field with a ValidationError on every poll, breaking the archive end-to-end. Mirrors the identical precedent for Task.updated_at (e550423, pyobs-portal#84). - Exclude updated_at from the project/task content-diff comparisons in Scheduler, so a no-op re-save (timestamp-only change) doesn't force a spurious reschedule. Task.model_dump(exclude=...) doesn't work here -- Task is a PolymorphicBaseModel whose custom @model_serializer ignores exclude/include entirely (filed separately as #855) -- so Scheduler._content_dump() strips the key from the plain dump instead. - Broaden the private-attr regression test to also cover _resolved_target/_running_script, not just _cant_run_reason. - Add tests for project removal and a mixed poll (running task removed + an unrelated, actually-scheduled task's content changed in the same update).
|
Addressed in 3b06cf1:
Full non-integration suite green again (1786 passed) after all of the above; |
thusser
left a comment
There was a problem hiding this comment.
Follow-up review (head 3b06cf16) — all findings addressed, approve
Re-verified everything from the previous review against the new head in the PR worktree:
| Previous finding | Status |
|---|---|
BLOCKER — Project rejects the portal's updated_at (extra="forbid") |
✅ Fixed: updated_at: str | None = None round-trip field on Project (mirroring Task's e550423e precedent) + test_task_get_projects_from_portal_accepts_updated_at archive regression test |
LOW — model_dump() compares updated_at → no-op saves force reschedules |
✅ Fixed, and it went deeper than the ask: updated_at is now excluded from both comparisons, with the PolymorphicBaseModel serializer-exclude trap found and worked around (_content_dump()), filed as #855 |
| LOW — private-attr regression test narrower than the plan | ✅ Fixed: now exercises _resolved_target/_running_script in addition to _cant_run_reason |
| Nits — missing project-removal and mixed removed+changed tests | ✅ Fixed: test_update_schedule_project_removed_triggers_update + test_update_schedule_mixed_removed_running_and_changed_scheduled_triggers_update (the latter correctly exercises the len(changed)==0 guard on the removal downgrade) |
Independent verification on the new head:
| Check | Result |
|---|---|
pytest tests/modules/robotic/test_scheduler.py tests/robotic/storage/portal/test_portal_archives.py |
107 passed |
ruff + black --check on the 4 changed files |
clean |
Serializer claim in _content_dump's docstring |
confirmed empirically: Task.model_dump(exclude={"updated_at"}) still contains updated_at (the PolymorphicBaseModel serializer ignores exclude entirely), while Project.model_dump(exclude={"updated_at"}) correctly drops it — so the pop-workaround for Task and the exclude= for Project are each the right tool, and #855 is a real, correctly-scoped bug |
| Plan doc + PR description | updated with the coupling note and the addendum; both accurate |
One small remaining observation (not blocking):
LOW — PortalTaskArchive._update() still compares with plain model_dump(), so updated_at still propagates one level. The archive (taskarchive.py:96-98) wasn't touched: a no-op re-save still moves the marker → archive re-polls → its content diff includes updated_at → fires on_tasks_changed → the scheduler re-downloads and re-diffs, then correctly concludes "no change" and skips the reschedule. End-to-end behavior is right, but a no-op save still triggers a full task/project re-download + scheduler _update_schedule() pass. Excluding updated_at from the archive's comparisons too (or reusing the same helper) would make no-op saves completely invisible and avoid the churn — optional, follow-up material.
Verdict: the blocker and all notes are resolved; this is ready to merge (together with pyobs-portal#134, per the corrected coupling note).
|
Verified all four points against the new head (
Local re-run: No further changes needed from my side — ready to merge together with pyobs-portal#134. |
…hedule-project-task-changes # Conflicts: # pyobs/modules/robotic/scheduler.py
|
Rebased onto `develop` — it had advanced with PR #852 (issue #847) in the meantime, which conflicted at the design level, not just textually: #852 removed the "was one of the removed tasks actually in `get_schedule()`?" guard entirely, because `PortalObservationArchive`'s schedule cache is permanently empty by construction, so the guard always found nothing and silently discarded every real portal task removal. My branch had reused that same guard for the new `changed_ids` set. Left in place, it would've reintroduced the identical bug one level up — every same-ID task content change silently swallowed on a portal deployment, defeating this issue's whole point. Dropped the guard for `changed_ids` too (never re-added it), matching #852's decision. Net effect: project changes, task add/remove, and same-ID content changes all now force a reschedule unconditionally, except the one remaining downgrade (a removal that's exactly the currently-running task ending on its own). This also simplifies the design — the running-task "asymmetry" and its bypass variable are gone, since there's no membership check left to bypass. Rewrote the affected tests to match (no longer mocking/asserting on `get_schedule()` for content changes, mirroring #852's own `..._without_consulting_schedule_cache` naming). Full suite: 1800 passed (up from 1786 pre-merge, reflecting #852's own additions). Documented in the plan's new "Merge-conflict addendum" section. |
…ask-changes Reschedule on project and same-ID task content changes
Summary
Scheduler._update_schedule()diffed task IDs only, so a project priority change or a same-ID task content change never triggered a reschedule even once the archive re-polled.self._projectswas overwritten before it could ever be diffed against the previous download.model_dump(), keyed bycode, mirroringPortalTaskArchive._update()) and a newScheduler._changed_task_ids()helper for same-ID task content diffs.Project.updated_atfor round-trip compatibility with pyobs-portal#134 (without it,Project'sextra="forbid"model rejects the portal's payload on every poll — see review below); excludesupdated_atfrom the content-diff comparisons so a no-op re-save doesn't force a spurious reschedule; broadens the private-attr regression test; adds coverage for project removal and a mixed removed+changed poll.Closes #848 (pyobs-core half — the portal-side `/api/last_task_update/` marker fix is pyobs-portal#134). Not independently landable as originally scoped — see the PR comment below; that's now fixed in this PR.
Also filed #855: `PolymorphicBaseModel`'s custom serializer silently ignores `model_dump(exclude=/include=)` for every subclass (`Task`, `Script`, `Constraint`, `Merit`, `Target`), found while fixing this — worked around locally here, real fix is a separate, wider-scoped change.
Plan: `specs/plans/2026-09-01-scheduler-reschedule-on-project-and-task-changes.md`
Test plan